From a6e5520f07b2caf9b3c822648b82fb134cf7184c Mon Sep 17 00:00:00 2001 From: Itay Date: Fri, 1 May 2026 07:38:07 +0300 Subject: [PATCH 001/275] Update bot.js --- src/config/bot.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/config/bot.js b/src/config/bot.js index 36e588cd42..70a4f5242d 100644 --- a/src/config/bot.js +++ b/src/config/bot.js @@ -12,7 +12,7 @@ export const botConfig = { // - "invisible" = appears offline presence: { // Current online state shown on Discord. - status: "online", + status: "dnd", // Activity lines shown under the bot name. // `type` number mapping from Discord: @@ -25,7 +25,7 @@ export const botConfig = { activities: [ { // Text users will see (example: "Playing /help | Titan Bot"). - name: "Made with โค๏ธ", + name: "clan zen on top", // Activity type number (0 = Playing). type: 0, }, @@ -88,7 +88,7 @@ export const botConfig = { embeds: { colors: { // Main brand colors. - primary: "#336699", + primary: "#00008", secondary: "#2F3136", // Standard status colors for success/error/warning/info messages. From afd3ece27d3596ee2ff1fe01e327754b73d7ab7a Mon Sep 17 00:00:00 2001 From: Itay100K Date: Fri, 1 May 2026 11:05:45 +0300 Subject: [PATCH 002/275] Customize bot for personal server - Fix Docker Compose: add POSTGRES_URL and POSTGRES_HOST=db so the bot connects to the db service instead of localhost - Update support server links to discord.gg/ECPhU7FWTA in help and support commands - Remove Report Bug and Learn from Touchpoint buttons from help menu - Add CLAUDE.md with architecture and development guidance Co-Authored-By: Claude Sonnet 4.6 --- .claude/settings.local.json | 12 +++++++++++ docker-compose.yml | 4 +++- src/commands/Core/help.js | 15 +------------ src/commands/Core/support.js | 2 +- src/handlers/helpButtons.js | 37 -------------------------------- src/interactions/buttons/help.js | 3 +-- 6 files changed, 18 insertions(+), 55 deletions(-) create mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000000..981aaf8345 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,12 @@ +{ + "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 })" + ] + } +} diff --git a/docker-compose.yml b/docker-compose.yml index b2fb7b982f..356f6069db 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,8 @@ services: - 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 depends_on: db: @@ -22,7 +24,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 diff --git a/src/commands/Core/help.js b/src/commands/Core/help.js index 4ad58be101..d9513113fd 100644 --- a/src/commands/Core/help.js +++ b/src/commands/Core/help.js @@ -18,7 +18,6 @@ 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 = { @@ -162,19 +161,9 @@ export async function createInitialHelpMenu(client) { }); 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") + .setURL("https://discord.gg/ECPhU7FWTA") .setStyle(ButtonStyle.Link); const selectRow = createSelectMenu( @@ -184,9 +173,7 @@ export async function createInitialHelpMenu(client) { ); const buttonRow = new ActionRowBuilder().addComponents([ - bugReportButton, supportButton, - touchpointButton, ]); return { diff --git a/src/commands/Core/support.js b/src/commands/Core/support.js index aa392e3a66..0c5fd6d3c2 100644 --- a/src/commands/Core/support.js +++ b/src/commands/Core/support.js @@ -3,7 +3,7 @@ import { createEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; -const SUPPORT_SERVER_URL = "https://discord.gg/QnWNz2dKCE"; +const SUPPORT_SERVER_URL = "https://discord.gg/ECPhU7FWTA"; export default { data: new SlashCommandBuilder() .setName("support") diff --git a/src/handlers/helpButtons.js b/src/handlers/helpButtons.js index 0c989452a2..07bc460115 100644 --- a/src/handlers/helpButtons.js +++ b/src/handlers/helpButtons.js @@ -1,13 +1,10 @@ -import { createEmbed } from '../utils/embeds.js'; import { createAllCommandsMenu } from './helpSelectMenus.js'; import { createInitialHelpMenu } from '../commands/Core/help.js'; -import { ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags } from 'discord.js'; import { logger } from '../utils/logger.js'; 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, @@ -38,40 +35,6 @@ 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() - }); - bugReportEmbed.setTimestamp(); - - await interaction.reply({ - embeds: [bugReportEmbed], - components: [bugRow], - flags: MessageFlags.Ephemeral - }); - }, -}; export const helpReportCommand = { name: COMMAND_LIST_ID, diff --git a/src/interactions/buttons/help.js b/src/interactions/buttons/help.js index 9e7a011262..f0f4a1d1f8 100644 --- a/src/interactions/buttons/help.js +++ b/src/interactions/buttons/help.js @@ -1,6 +1,5 @@ import { helpBackButton, - helpBugReportButton, helpPaginationButton, } from '../../handlers/helpButtons.js'; @@ -16,4 +15,4 @@ const paginationInteractions = paginationIds.map((name) => ({ execute: helpPaginationButton.execute, })); -export default [helpBackButton, helpBugReportButton, ...paginationInteractions]; \ No newline at end of file +export default [helpBackButton, ...paginationInteractions]; \ No newline at end of file From 0a5b49c7e285b424f6c69180789b8de6d3c7dab5 Mon Sep 17 00:00:00 2001 From: Itay100K Date: Fri, 1 May 2026 11:06:02 +0300 Subject: [PATCH 003/275] claude.md --- CLAUDE.md | 72 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 CLAUDE.md 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. From e5cdbe5cf934a91cf759689400b38ff772692ab5 Mon Sep 17 00:00:00 2001 From: Itay100K Date: Fri, 1 May 2026 12:59:53 +0300 Subject: [PATCH 004/275] Add new commands and rename bot to itay100k bot - Add /avatar, /userinfo, /serverinfo, /slowmode commands - Upgrade /purge to use a modal popup for message count input - Rename bot to itay100k bot (embed footer + Discord username) Co-Authored-By: Claude Sonnet 4.6 --- .claude/settings.local.json | 3 +- src/commands/Core/serverinfo.js | 54 +++++++ src/commands/Core/userinfo.js | 67 +++++++++ src/commands/Fun/avatar.js | 43 ++++++ src/commands/Moderation/purge.js | 226 +++++++++++++--------------- src/commands/Moderation/slowmode.js | 55 +++++++ src/config/bot.js | 13 +- src/events/ready.js | 20 ++- 8 files changed, 348 insertions(+), 133 deletions(-) create mode 100644 src/commands/Core/serverinfo.js create mode 100644 src/commands/Core/userinfo.js create mode 100644 src/commands/Fun/avatar.js create mode 100644 src/commands/Moderation/slowmode.js diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 981aaf8345..d144726b34 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -6,7 +6,8 @@ "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 })" + "PowerShell(Get-ChildItem -Path \"c:\\\\code\\\\zen-bot\\\\src\\\\commands\" | Where-Object { $_.PSIsContainer } | ForEach-Object { $_.Name })", + "Bash(docker compose *)" ] } } diff --git a/src/commands/Core/serverinfo.js b/src/commands/Core/serverinfo.js new file mode 100644 index 0000000000..874477608e --- /dev/null +++ b/src/commands/Core/serverinfo.js @@ -0,0 +1,54 @@ +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'; + +const VERIFICATION_LEVELS = ['None', 'Low', 'Medium', 'High', 'Highest']; + +export default { + data: new SlashCommandBuilder() + .setName('serverinfo') + .setDescription('Display information about this server'), + + async execute(interaction) { + try { + await InteractionHelper.safeDefer(interaction); + + const guild = interaction.guild; + await guild.fetch(); + + const owner = await guild.fetchOwner().catch(() => null); + const createdAt = Math.floor(guild.createdTimestamp / 1000); + + const textChannels = guild.channels.cache.filter(c => c.type === 0).size; + const voiceChannels = guild.channels.cache.filter(c => c.type === 2).size; + const categories = guild.channels.cache.filter(c => c.type === 4).size; + const bots = guild.members.cache.filter(m => m.user.bot).size; + const humans = guild.memberCount - bots; + + const embed = createEmbed({ + title: `Server Info โ€” ${guild.name}`, + thumbnail: guild.iconURL({ size: 256 }), + color: 'blurple', + fields: [ + { name: 'Owner', value: owner ? `${owner}` : 'Unknown', inline: true }, + { name: 'Server ID', value: guild.id, inline: true }, + { name: 'Created', value: ` ()`, inline: false }, + { name: 'Members', value: `**${guild.memberCount}** total โ€” ${humans} humans, ${bots} bots`, inline: false }, + { name: 'Channels', value: `${textChannels} text ยท ${voiceChannels} voice ยท ${categories} categories`, inline: false }, + { name: 'Roles', value: `${guild.roles.cache.size}`, inline: true }, + { name: 'Emojis', value: `${guild.emojis.cache.size}`, inline: true }, + { name: 'Boosts', value: `${guild.premiumSubscriptionCount} (Level ${guild.premiumTier})`, inline: true }, + { name: 'Verification', value: VERIFICATION_LEVELS[guild.verificationLevel] ?? 'Unknown', inline: true }, + ], + }); + + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); + logger.debug(`Serverinfo command used by ${interaction.user.id} in guild ${guild.id}`); + } catch (error) { + logger.error('Serverinfo command error:', error); + await handleInteractionError(interaction, error, { commandName: 'serverinfo' }); + } + }, +}; diff --git a/src/commands/Core/userinfo.js b/src/commands/Core/userinfo.js new file mode 100644 index 0000000000..3a59e96748 --- /dev/null +++ b/src/commands/Core/userinfo.js @@ -0,0 +1,67 @@ +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('View detailed information about a user') + .addUserOption(option => + option.setName('user').setDescription('The user to look up').setRequired(false) + ), + + async execute(interaction) { + try { + await InteractionHelper.safeDefer(interaction); + + const targetUser = interaction.options.getUser('user') || interaction.user; + const member = await interaction.guild?.members.fetch(targetUser.id).catch(() => null); + + const createdAt = Math.floor(targetUser.createdTimestamp / 1000); + const joinedAt = member?.joinedTimestamp ? Math.floor(member.joinedTimestamp / 1000) : null; + + const topRoles = member?.roles.cache + .filter(r => r.id !== interaction.guild.id) + .sort((a, b) => b.position - a.position) + .map(r => r.toString()) + .slice(0, 10) || []; + + const roleCount = (member?.roles.cache.size ?? 1) - 1; + + const fields = [ + { name: 'User ID', value: targetUser.id, inline: true }, + { name: 'Bot', value: targetUser.bot ? 'Yes' : 'No', inline: true }, + { name: 'Account Created', value: ` ()`, inline: false }, + ]; + + if (joinedAt) { + fields.push({ name: 'Joined Server', value: ` ()`, inline: false }); + } + if (member?.nickname) { + fields.push({ name: 'Nickname', value: member.nickname, inline: true }); + } + if (member?.premiumSinceTimestamp) { + fields.push({ name: 'Boosting Since', value: ``, inline: true }); + } + if (topRoles.length > 0) { + const overflow = roleCount > 10 ? ` (+${roleCount - 10} more)` : ''; + fields.push({ name: `Roles (${roleCount})`, value: topRoles.join(' ') + overflow, inline: false }); + } + + const embed = createEmbed({ + title: `User Info โ€” ${targetUser.username}`, + thumbnail: targetUser.displayAvatarURL({ size: 256 }), + fields, + color: targetUser.bot ? 'blurple' : 'primary', + }); + + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); + logger.debug(`Userinfo command used for ${targetUser.id} by ${interaction.user.id}`); + } catch (error) { + logger.error('Userinfo command error:', error); + await handleInteractionError(interaction, error, { commandName: 'userinfo' }); + } + }, +}; diff --git a/src/commands/Fun/avatar.js b/src/commands/Fun/avatar.js new file mode 100644 index 0000000000..66ed80995a --- /dev/null +++ b/src/commands/Fun/avatar.js @@ -0,0 +1,43 @@ +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('avatar') + .setDescription("Display a user's avatar in full size") + .addUserOption(option => + option.setName('user').setDescription('The user whose avatar to display').setRequired(false) + ), + + async execute(interaction) { + try { + const targetUser = interaction.options.getUser('user') || interaction.user; + const member = await interaction.guild?.members.fetch(targetUser.id).catch(() => null); + + const globalAvatar = targetUser.displayAvatarURL({ size: 4096 }); + const serverAvatar = member?.avatarURL({ size: 4096 }); + + const displayAvatar = serverAvatar || globalAvatar; + const links = [`[Global Avatar](${globalAvatar})`]; + if (serverAvatar && serverAvatar !== globalAvatar) { + links.unshift(`[Server Avatar](${serverAvatar})`); + } + + const embed = createEmbed({ + title: `${targetUser.username}'s Avatar`, + description: links.join(' โ€ข '), + image: displayAvatar, + color: 'blurple', + }); + + await InteractionHelper.safeReply(interaction, { embeds: [embed] }); + logger.debug(`Avatar command used for ${targetUser.id} by ${interaction.user.id}`); + } catch (error) { + logger.error('Avatar command error:', error); + await handleInteractionError(interaction, error, { commandName: 'avatar' }); + } + }, +}; diff --git a/src/commands/Moderation/purge.js b/src/commands/Moderation/purge.js index 1420ea75fc..4da3efe3fb 100644 --- a/src/commands/Moderation/purge.js +++ b/src/commands/Moderation/purge.js @@ -1,135 +1,113 @@ -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, MessageFlags } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; +import { + SlashCommandBuilder, + PermissionFlagsBits, + MessageFlags, + ModalBuilder, + TextInputBuilder, + TextInputStyle, + ActionRowBuilder, +} from 'discord.js'; +import { errorEmbed, successEmbed, 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 - } + .setName('purge') + .setDescription('Delete a bulk of messages in this channel') + .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 need the `Manage Messages` permission to purge messages.')], + flags: [MessageFlags.Ephemeral], + }); } - }); - - 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, - }); - } - } -}; + const modal = new ModalBuilder() + .setCustomId('purge_count') + .setTitle('Delete Messages'); + + const amountInput = new TextInputBuilder() + .setCustomId('amount') + .setLabel('How many messages to delete? (1โ€“100)') + .setStyle(TextInputStyle.Short) + .setPlaceholder('e.g. 10') + .setMinLength(1) + .setMaxLength(3) + .setRequired(true); + + modal.addComponents(new ActionRowBuilder().addComponents(amountInput)); + await interaction.showModal(modal); + + const modalSubmit = await interaction.awaitModalSubmit({ + filter: i => i.customId === 'purge_count' && i.user.id === interaction.user.id, + time: 60_000, + }).catch(() => null); + + if (!modalSubmit) return; + + const amountStr = modalSubmit.fields.getTextInputValue('amount').trim(); + const amount = parseInt(amountStr, 10); + + if (isNaN(amount) || amount < 1 || amount > 100) { + return modalSubmit.reply({ + embeds: [errorEmbed('Invalid Amount', 'Please enter a whole number between 1 and 100.')], + flags: [MessageFlags.Ephemeral], + }); + } + + await modalSubmit.deferReply({ flags: [MessageFlags.Ephemeral] }); + + const rateLimitKey = `purge_${interaction.user.id}`; + const isAllowed = await checkRateLimit(rateLimitKey, 5, 60_000); + if (!isAllowed) { + return modalSubmit.editReply({ + embeds: [warningEmbed('Rate Limited', "You're purging too fast. Please wait a minute before trying again.")], + }); + } + + const channel = interaction.channel; + try { + const fetched = await channel.messages.fetch({ limit: amount }); + const deleted = await channel.bulkDelete(fetched, true); + const deletedCount = deleted.size; + await logEvent({ + client, + guild: interaction.guild, + event: { + action: 'Messages Purged', + target: `${channel} (${deletedCount} messages)`, + executor: `${interaction.user.username} (${interaction.user.id})`, + reason: `Deleted ${deletedCount} messages`, + metadata: { + channelId: channel.id, + messageCount: deletedCount, + requestedAmount: amount, + moderatorId: interaction.user.id, + }, + }, + }); + + await modalSubmit.editReply({ + embeds: [successEmbed('Messages Deleted', `Deleted **${deletedCount}** messages in ${channel}.`)], + }); + + setTimeout(() => modalSubmit.deleteReply().catch(() => {}), 4000); + + logger.info(`Purge: ${deletedCount} messages deleted in ${channel.id} by ${interaction.user.id}`); + } catch (error) { + logger.error('Purge command error:', error); + await modalSubmit.editReply({ + embeds: [errorEmbed('Delete Failed', 'Could not delete messages. Note: messages older than 14 days cannot be bulk deleted.')], + }); + } + }, +}; diff --git a/src/commands/Moderation/slowmode.js b/src/commands/Moderation/slowmode.js new file mode 100644 index 0000000000..f0f2a6202a --- /dev/null +++ b/src/commands/Moderation/slowmode.js @@ -0,0 +1,55 @@ +import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; +import { successEmbed, errorEmbed } 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('slowmode') + .setDescription('Set the slowmode delay for a text channel') + .addIntegerOption(option => + option + .setName('seconds') + .setDescription('Delay in seconds (0 to disable, max 21600)') + .setMinValue(0) + .setMaxValue(21600) + .setRequired(true) + ) + .addChannelOption(option => + option + .setName('channel') + .setDescription('Channel to apply slowmode to (defaults to current channel)') + .setRequired(false) + ) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels), + + async execute(interaction) { + try { + const seconds = interaction.options.getInteger('seconds'); + const channel = interaction.options.getChannel('channel') || interaction.channel; + + if (!channel.isTextBased() || channel.isDMBased()) { + return InteractionHelper.safeReply(interaction, { + embeds: [errorEmbed('Invalid Channel', 'Slowmode can only be applied to text channels.')], + flags: [MessageFlags.Ephemeral], + }); + } + + await channel.setRateLimitPerUser(seconds, `Set by ${interaction.user.username} via /slowmode`); + + const description = seconds === 0 + ? `Slowmode has been **disabled** in ${channel}.` + : `Slowmode set to **${seconds}s** in ${channel}. Users must wait ${seconds}s between messages.`; + + await InteractionHelper.safeReply(interaction, { + embeds: [successEmbed('Slowmode Updated', description)], + }); + + logger.info(`Slowmode set to ${seconds}s in channel ${channel.id} by ${interaction.user.id}`); + } catch (error) { + logger.error('Slowmode command error:', error); + await handleInteractionError(interaction, error, { commandName: 'slowmode' }); + } + }, +}; diff --git a/src/config/bot.js b/src/config/bot.js index 70a4f5242d..d6273a6e3d 100644 --- a/src/config/bot.js +++ b/src/config/bot.js @@ -22,13 +22,12 @@ export const botConfig = { // 3 = Watching // 4 = Custom // 5 = Competing + // Rotates every 30 seconds. Add/remove entries here to customize. + // type: 0=Playing, 2=Listening, 3=Watching, 5=Competing activities: [ - { - // Text users will see (example: "Playing /help | Titan Bot"). - name: "clan zen on top", - // Activity type number (0 = Playing). - type: 0, - }, + { name: "clan zen on top", type: 0 }, + { name: "/help", type: 3 }, + { name: "zen vibes", type: 2 }, ], }, @@ -136,7 +135,7 @@ export const botConfig = { }, footer: { // Default footer text used in bot embeds. - text: "Titan Bot", + text: "itay100k bot", // Footer icon URL (null = no icon). icon: null, }, diff --git a/src/events/ready.js b/src/events/ready.js index 8f3db50966..07a4683cfa 100644 --- a/src/events/ready.js +++ b/src/events/ready.js @@ -9,7 +9,25 @@ export default { async execute(client) { try { - client.user.setPresence(config.bot.presence); + const { status, activities } = config.bot.presence; + let activityIndex = 0; + + const setActivity = () => { + client.user.setPresence({ + status, + activities: [activities[activityIndex]], + }); + activityIndex = (activityIndex + 1) % activities.length; + }; + + setActivity(); + setInterval(setActivity, 30_000); + + if (client.user.username !== 'itay100k bot') { + await client.user.setUsername('itay100k bot').catch(err => + logger.warn('Could not update bot username (rate limited?):', err.message) + ); + } startupLog(`Ready! Logged in as ${client.user.tag}`); startupLog(`Serving ${client.guilds.cache.size} guild(s)`); From d4ec60e2ca3013df533a438b39c57e4e1a2d00f4 Mon Sep 17 00:00:00 2001 From: Itay100K Date: Fri, 1 May 2026 13:06:11 +0300 Subject: [PATCH 005/275] Add /fakemessage command, remove /wanted Co-Authored-By: Claude Sonnet 4.6 --- src/commands/Fun/fakemessage.js | 63 +++++++++++++++++++++++ src/commands/Fun/wanted.js | 89 --------------------------------- 2 files changed, 63 insertions(+), 89 deletions(-) create mode 100644 src/commands/Fun/fakemessage.js delete mode 100644 src/commands/Fun/wanted.js diff --git a/src/commands/Fun/fakemessage.js b/src/commands/Fun/fakemessage.js new file mode 100644 index 0000000000..e9e99af158 --- /dev/null +++ b/src/commands/Fun/fakemessage.js @@ -0,0 +1,63 @@ +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'; + +export default { + data: new SlashCommandBuilder() + .setName('fakemessage') + .setDescription('Send a message that appears to be from another user') + .addUserOption(option => + option.setName('user').setDescription('The user to impersonate').setRequired(true) + ) + .addStringOption(option => + option.setName('message').setDescription('The message to send').setRequired(true).setMaxLength(2000) + ) + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), + + async execute(interaction) { + try { + const targetUser = interaction.options.getUser('user'); + const message = interaction.options.getString('message'); + const member = await interaction.guild.members.fetch(targetUser.id).catch(() => null); + + const displayName = member?.displayName ?? targetUser.username; + const avatarURL = targetUser.displayAvatarURL({ size: 256 }); + + if (!interaction.channel.isTextBased() || interaction.channel.isDMBased()) { + return InteractionHelper.safeReply(interaction, { + embeds: [errorEmbed('Invalid Channel', 'This command can only be used in text channels.')], + flags: [MessageFlags.Ephemeral], + }); + } + + // Reuse an existing webhook or create a new one + const webhooks = await interaction.channel.fetchWebhooks(); + let webhook = webhooks.find(wh => wh.owner?.id === interaction.client.user.id); + + if (!webhook) { + webhook = await interaction.channel.createWebhook({ + name: 'itay100k bot', + avatar: interaction.client.user.displayAvatarURL(), + }); + } + + await webhook.send({ + content: message, + username: displayName, + avatarURL, + }); + + await InteractionHelper.safeReply(interaction, { + embeds: [successEmbed('Sent', `Message sent as **${displayName}**.`)], + flags: [MessageFlags.Ephemeral], + }); + + logger.info(`Fakemessage: ${interaction.user.id} impersonated ${targetUser.id} in ${interaction.channel.id}`); + } catch (error) { + logger.error('Fakemessage command error:', error); + await handleInteractionError(interaction, error, { commandName: 'fakemessage' }); + } + }, +}; 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' - }); - } - }, -}; - - - From 69e75f2b2aaefb6a6d8a88b1c9d7c87e0e8cd2da Mon Sep 17 00:00:00 2001 From: Itay100K Date: Fri, 1 May 2026 13:08:53 +0300 Subject: [PATCH 006/275] Remove birthday commands Co-Authored-By: Claude Sonnet 4.6 --- src/commands/Birthday/birthday.js | 118 ------------------ .../Birthday/modules/birthday_info.js | 64 ---------- .../Birthday/modules/birthday_list.js | 99 --------------- .../Birthday/modules/birthday_remove.js | 52 -------- src/commands/Birthday/modules/birthday_set.js | 44 ------- .../Birthday/modules/birthday_setchannel.js | 44 ------- .../Birthday/modules/next_birthdays.js | 102 --------------- 7 files changed, 523 deletions(-) delete mode 100644 src/commands/Birthday/birthday.js delete mode 100644 src/commands/Birthday/modules/birthday_info.js delete mode 100644 src/commands/Birthday/modules/birthday_list.js delete mode 100644 src/commands/Birthday/modules/birthday_remove.js delete mode 100644 src/commands/Birthday/modules/birthday_set.js delete mode 100644 src/commands/Birthday/modules/birthday_setchannel.js delete mode 100644 src/commands/Birthday/modules/next_birthdays.js 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' - }); - } - } -}; - - - From ab799e3bce54867b85288e211a89a2fd8f590417 Mon Sep 17 00:00:00 2001 From: Itay100K Date: Fri, 1 May 2026 13:11:27 +0300 Subject: [PATCH 007/275] Add /changelog command and changelog config Co-Authored-By: Claude Sonnet 4.6 --- src/commands/Core/changelog.js | 54 ++++++++++++++++++++++++++++++++++ src/config/changelog.js | 37 +++++++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 src/commands/Core/changelog.js create mode 100644 src/config/changelog.js diff --git a/src/commands/Core/changelog.js b/src/commands/Core/changelog.js new file mode 100644 index 0000000000..420b112806 --- /dev/null +++ b/src/commands/Core/changelog.js @@ -0,0 +1,54 @@ +import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; +import { createEmbed, errorEmbed } from '../../utils/embeds.js'; +import { logger } from '../../utils/logger.js'; +import { handleInteractionError } from '../../utils/errorHandler.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { changelog, typeEmoji } from '../../config/changelog.js'; + +export default { + data: new SlashCommandBuilder() + .setName('changelog') + .setDescription('Post the bot changelog to this channel') + .addBooleanOption(option => + option + .setName('all') + .setDescription('Show all versions instead of just the latest') + .setRequired(false) + ) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild), + + async execute(interaction) { + try { + const showAll = interaction.options.getBoolean('all') ?? false; + const versions = showAll ? changelog : [changelog[0]]; + + await InteractionHelper.safeDefer(interaction); + + for (const version of versions) { + const lines = version.entries.map(e => + `${typeEmoji[e.type] ?? 'โ€ข'} ${e.text}` + ).join('\n'); + + const embed = createEmbed({ + title: `๐Ÿ“‹ Changelog โ€” v${version.version}`, + description: lines, + color: 'blurple', + footer: { text: `Released ${version.date}` }, + timestamp: false, + }); + + await interaction.channel.send({ embeds: [embed] }); + } + + await InteractionHelper.safeEditReply(interaction, { + content: `Posted ${versions.length === 1 ? 'latest version' : 'all versions'}.`, + flags: [MessageFlags.Ephemeral], + }); + + logger.info(`Changelog posted by ${interaction.user.id} in ${interaction.channel.id}`); + } catch (error) { + logger.error('Changelog command error:', error); + await handleInteractionError(interaction, error, { commandName: 'changelog' }); + } + }, +}; diff --git a/src/config/changelog.js b/src/config/changelog.js new file mode 100644 index 0000000000..1e8ae3d3f6 --- /dev/null +++ b/src/config/changelog.js @@ -0,0 +1,37 @@ +export const changelog = [ + { + 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: '๐Ÿ†•', + changed: 'โœ๏ธ', + fixed: '๐Ÿ›', + removed: '๐Ÿ—‘๏ธ', +}; From 76316da51e29006302a954ff5e6a9fdc613d0d7d Mon Sep 17 00:00:00 2001 From: Itay100K Date: Fri, 1 May 2026 13:12:44 +0300 Subject: [PATCH 008/275] Update changelog with v2.1.1 entry Co-Authored-By: Claude Sonnet 4.6 --- src/config/changelog.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/config/changelog.js b/src/config/changelog.js index 1e8ae3d3f6..5134443b5c 100644 --- a/src/config/changelog.js +++ b/src/config/changelog.js @@ -1,4 +1,11 @@ export const changelog = [ + { + 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', From 00506a23f8ea90465ffc88835b369df6fdc049f2 Mon Sep 17 00:00:00 2001 From: Itay100K Date: Fri, 1 May 2026 13:16:40 +0300 Subject: [PATCH 009/275] Update Claude Code local settings Co-Authored-By: Claude Sonnet 4.6 --- .claude/settings.local.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index d144726b34..74ab98f754 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -7,7 +7,11 @@ "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(docker compose *)", + "Bash(git rm *)", + "Bash(git add *)", + "Bash(git commit -m ' *)", + "Bash(git push *)" ] } } From aa36d1da5276a23ee19465c6dc833db18c5eb4a0 Mon Sep 17 00:00:00 2001 From: Itay100K Date: Fri, 1 May 2026 13:24:23 +0300 Subject: [PATCH 010/275] Switch to global command registration for multi-server support - Commands now register globally instead of per-guild - Clears leftover guild-specific commands on startup - Logs an invite link on startup for easy server addition Co-Authored-By: Claude Sonnet 4.6 --- src/app.js | 13 ++++++++++++- src/events/ready.js | 3 +++ src/handlers/commandLoader.js | 35 +++++++++++++++++++---------------- 3 files changed, 34 insertions(+), 17 deletions(-) diff --git a/src/app.js b/src/app.js index c0625b6b5a..328cb4a762 100644 --- a/src/app.js +++ b/src/app.js @@ -301,7 +301,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); } diff --git a/src/events/ready.js b/src/events/ready.js index 07a4683cfa..69685267db 100644 --- a/src/events/ready.js +++ b/src/events/ready.js @@ -30,6 +30,9 @@ export default { } startupLog(`Ready! Logged in as ${client.user.tag}`); + + const inviteUrl = `https://discord.com/oauth2/authorize?client_id=${client.user.id}&scope=bot+applications.commands&permissions=8`; + startupLog(`Invite link: ${inviteUrl}`); startupLog(`Serving ${client.guilds.cache.size} guild(s)`); startupLog(`Loaded ${client.commands.size} commands`); diff --git a/src/handlers/commandLoader.js b/src/handlers/commandLoader.js index 5273f2934d..697c6966bf 100644 --- a/src/handlers/commandLoader.js +++ b/src/handlers/commandLoader.js @@ -171,15 +171,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 +252,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 +286,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); From f21c31cc343c7a8245474bcd45c42b3f455adfea Mon Sep 17 00:00:00 2001 From: Itay100K Date: Fri, 1 May 2026 14:51:02 +0300 Subject: [PATCH 011/275] Extract botConfig into separate file Co-Authored-By: Claude Sonnet 4.6 --- src/config/bot.js | 463 +--------------------------------------- src/config/botConfig.js | 456 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 457 insertions(+), 462 deletions(-) create mode 100644 src/config/botConfig.js diff --git a/src/config/bot.js b/src/config/bot.js index d6273a6e3d..fe35556587 100644 --- a/src/config/bot.js +++ b/src/config/bot.js @@ -1,466 +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: "dnd", - - // Activity lines shown under the bot name. - // `type` number mapping from Discord: - // 0 = Playing - // 1 = Streaming - // 2 = Listening - // 3 = Watching - // 4 = Custom - // 5 = Competing - // Rotates every 30 seconds. Add/remove entries here to customize. - // type: 0=Playing, 2=Listening, 3=Watching, 5=Competing - activities: [ - { name: "clan zen on top", type: 0 }, - { name: "/help", type: 3 }, - { name: "zen vibes", type: 2 }, - ], - }, - - // ========================= - // 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: "#00008", - 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: "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, - }, - }, - - // ========================= - // 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..e212ceaf0c --- /dev/null +++ b/src/config/botConfig.js @@ -0,0 +1,456 @@ + + +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: "dnd", + + // Activity lines shown under the bot name. + // `type` number mapping from Discord: + // 0 = Playing + // 1 = Streaming + // 2 = Listening + // 3 = Watching + // 4 = Custom + // 5 = Competing + // Rotates every 30 seconds. Add/remove entries here to customize. + // type: 0=Playing, 2=Listening, 3=Watching, 5=Competing + activities: [ + { name: "clan zen on top", type: 0 }, + { name: "/help", type: 3 }, + { name: "zen vibes", type: 2 }, + { name: "itay100k build this bot", type: 5 }, + ], + }, + + // ========================= + // 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: "#00008", + 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: "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, + }, + }, + + // ========================= + // 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, + }, +}; From 5b9ee67f85ec521f1da518f3ac64eafeb9ef7618 Mon Sep 17 00:00:00 2001 From: Itay100K Date: Fri, 1 May 2026 14:53:51 +0300 Subject: [PATCH 012/275] Fix botConfig import in shop config to use default export Co-Authored-By: Claude Sonnet 4.6 --- src/config/shop/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/shop/index.js b/src/config/shop/index.js index ca9831a664..7a8a0d6e20 100644 --- a/src/config/shop/index.js +++ b/src/config/shop/index.js @@ -4,7 +4,7 @@ import { shopItems, getItemById, getItemsByType, getItemPrice, validatePurchase } from './items.js'; -import { botConfig } from '../bot.js'; +import botConfig from '../bot.js'; const { currency } = botConfig.economy; From a6b3348952e26b3337af8da992c8fc8d10715e5c Mon Sep 17 00:00:00 2001 From: Itay100K Date: Fri, 1 May 2026 21:14:45 +0300 Subject: [PATCH 013/275] v2.1.2: fakemessage webhook support, help menu cleanup, botConfig import fixes - /fakemessage: add optional webhook_url param to send to any server; update description - /changelog: defer ephemerally so confirmation is only visible to the caller - /help: remove Birthday, Config, Counter fields that had no command directories - Fix botConfig named imports across verification, economy, and services Co-Authored-By: Claude Sonnet 4.6 --- src/commands/Core/changelog.js | 2 +- src/commands/Core/help.js | 18 ------ src/commands/Economy/beg.js | 2 +- src/commands/Fun/fakemessage.js | 60 ++++++++++++------- .../Verification/modules/autoVerify.js | 2 +- .../modules/autoVerifyDashboard.js | 2 +- .../modules/verification_dashboard.js | 2 +- src/commands/Verification/verification.js | 2 +- src/config/changelog.js | 10 ++++ src/services/verificationService.js | 2 +- 10 files changed, 54 insertions(+), 48 deletions(-) diff --git a/src/commands/Core/changelog.js b/src/commands/Core/changelog.js index 420b112806..714e67da1b 100644 --- a/src/commands/Core/changelog.js +++ b/src/commands/Core/changelog.js @@ -22,7 +22,7 @@ export default { const showAll = interaction.options.getBoolean('all') ?? false; const versions = showAll ? changelog : [changelog[0]]; - await InteractionHelper.safeDefer(interaction); + await InteractionHelper.safeDefer(interaction, { ephemeral: true }); for (const version of versions) { const lines = version.entries.map(e => diff --git a/src/commands/Core/help.js b/src/commands/Core/help.js index d9513113fd..225b49a152 100644 --- a/src/commands/Core/help.js +++ b/src/commands/Core/help.js @@ -30,13 +30,10 @@ const CATEGORY_ICONS = { Ticket: "๐ŸŽซ", Welcome: "๐Ÿ‘‹", Giveaway: "๐ŸŽ‰", - Counter: "๐Ÿ”ข", Tools: "๐Ÿ› ๏ธ", Search: "๐Ÿ”", Reaction_Roles: "๐ŸŽญ", Community: "๐Ÿ‘ฅ", - Birthday: "๐ŸŽ‚", - Config: "โš™๏ธ", }; @@ -114,26 +111,11 @@ export async function createInitialHelpMenu(client) { 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", diff --git a/src/commands/Economy/beg.js b/src/commands/Economy/beg.js index 260ebc656f..4833085345 100644 --- a/src/commands/Economy/beg.js +++ b/src/commands/Economy/beg.js @@ -1,7 +1,7 @@ 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 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'; diff --git a/src/commands/Fun/fakemessage.js b/src/commands/Fun/fakemessage.js index e9e99af158..a662deffb5 100644 --- a/src/commands/Fun/fakemessage.js +++ b/src/commands/Fun/fakemessage.js @@ -1,53 +1,67 @@ -import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; +import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, WebhookClient } 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'; +const DISCORD_WEBHOOK_REGEX = /^https:\/\/discord(?:app)?\.com\/api\/webhooks\/\d+\/.+$/; + export default { data: new SlashCommandBuilder() .setName('fakemessage') - .setDescription('Send a message that appears to be from another user') + .setDescription('Create a fake Discord message') .addUserOption(option => option.setName('user').setDescription('The user to impersonate').setRequired(true) ) .addStringOption(option => option.setName('message').setDescription('The message to send').setRequired(true).setMaxLength(2000) ) + .addStringOption(option => + option.setName('webhook_url').setDescription('Webhook URL of a channel in any server (leave empty to use current channel)').setRequired(false) + ) .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), async execute(interaction) { try { const targetUser = interaction.options.getUser('user'); const message = interaction.options.getString('message'); - const member = await interaction.guild.members.fetch(targetUser.id).catch(() => null); + const webhookUrl = interaction.options.getString('webhook_url'); + const member = await interaction.guild.members.fetch(targetUser.id).catch(() => null); const displayName = member?.displayName ?? targetUser.username; const avatarURL = targetUser.displayAvatarURL({ size: 256 }); - if (!interaction.channel.isTextBased() || interaction.channel.isDMBased()) { - return InteractionHelper.safeReply(interaction, { - embeds: [errorEmbed('Invalid Channel', 'This command can only be used in text channels.')], - flags: [MessageFlags.Ephemeral], - }); - } + if (webhookUrl) { + if (!DISCORD_WEBHOOK_REGEX.test(webhookUrl)) { + return InteractionHelper.safeReply(interaction, { + embeds: [errorEmbed('Invalid Webhook', 'Please provide a valid Discord webhook URL.')], + flags: [MessageFlags.Ephemeral], + }); + } - // Reuse an existing webhook or create a new one - const webhooks = await interaction.channel.fetchWebhooks(); - let webhook = webhooks.find(wh => wh.owner?.id === interaction.client.user.id); + const client = new WebhookClient({ url: webhookUrl }); + await client.send({ content: message, username: displayName, avatarURL }); + client.destroy(); + } else { + if (!interaction.channel.isTextBased() || interaction.channel.isDMBased()) { + return InteractionHelper.safeReply(interaction, { + embeds: [errorEmbed('Invalid Channel', 'This command can only be used in text channels.')], + flags: [MessageFlags.Ephemeral], + }); + } - if (!webhook) { - webhook = await interaction.channel.createWebhook({ - name: 'itay100k bot', - avatar: interaction.client.user.displayAvatarURL(), - }); - } + const webhooks = await interaction.channel.fetchWebhooks(); + let webhook = webhooks.find(wh => wh.owner?.id === interaction.client.user.id); - await webhook.send({ - content: message, - username: displayName, - avatarURL, - }); + if (!webhook) { + webhook = await interaction.channel.createWebhook({ + name: 'itay100k bot', + avatar: interaction.client.user.displayAvatarURL(), + }); + } + + await webhook.send({ content: message, username: displayName, avatarURL }); + } await InteractionHelper.safeReply(interaction, { embeds: [successEmbed('Sent', `Message sent as **${displayName}**.`)], diff --git a/src/commands/Verification/modules/autoVerify.js b/src/commands/Verification/modules/autoVerify.js index 3b0fd28dcc..42686b1a6d 100644 --- a/src/commands/Verification/modules/autoVerify.js +++ b/src/commands/Verification/modules/autoVerify.js @@ -1,4 +1,4 @@ -import { botConfig, getColor } from '../../../config/bot.js'; +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'; diff --git a/src/commands/Verification/modules/autoVerifyDashboard.js b/src/commands/Verification/modules/autoVerifyDashboard.js index 1b3834e192..8ea1643b16 100644 --- a/src/commands/Verification/modules/autoVerifyDashboard.js +++ b/src/commands/Verification/modules/autoVerifyDashboard.js @@ -1,4 +1,4 @@ -import { botConfig, getColor } from '../../../config/bot.js'; +import botConfig, { getColor } from '../../../config/bot.js'; import { ActionRowBuilder, StringSelectMenuBuilder, diff --git a/src/commands/Verification/modules/verification_dashboard.js b/src/commands/Verification/modules/verification_dashboard.js index 371701581b..788a19f207 100644 --- a/src/commands/Verification/modules/verification_dashboard.js +++ b/src/commands/Verification/modules/verification_dashboard.js @@ -1,4 +1,4 @@ -import { botConfig, getColor } from '../../../config/bot.js'; +import botConfig, { getColor } from '../../../config/bot.js'; import { ActionRowBuilder, StringSelectMenuBuilder, diff --git a/src/commands/Verification/verification.js b/src/commands/Verification/verification.js index 5852451833..e43ce5e05c 100644 --- a/src/commands/Verification/verification.js +++ b/src/commands/Verification/verification.js @@ -1,4 +1,4 @@ -import { botConfig, getColor } from '../../config/bot.js'; +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'; diff --git a/src/config/changelog.js b/src/config/changelog.js index 5134443b5c..6eb374961d 100644 --- a/src/config/changelog.js +++ b/src/config/changelog.js @@ -1,4 +1,14 @@ export const changelog = [ + { + 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', 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'; From 9fcce8374373e80e73807a7a6dc3d8c85ad894ea Mon Sep 17 00:00:00 2001 From: Itay100K Date: Fri, 1 May 2026 21:20:13 +0300 Subject: [PATCH 014/275] Remove economy system; add Search and Tools to help menu - Delete all Economy commands, shop config, economy/shop services and utils - Remove Economy from /help embed and select menu dropdown - Add Search and Tools sections to /help embed (work in every server) - Strip economy key patterns from wipedata handlers and utilityService - Remove economy/shop config from botConfig and application config Co-Authored-By: Claude Sonnet 4.6 --- .claude/settings.local.json | 5 +- src/commands/Core/help.js | 39 +- src/commands/Economy/balance.js | 86 --- src/commands/Economy/beg.js | 103 ---- src/commands/Economy/buy.js | 163 ----- src/commands/Economy/crime.js | 122 ---- src/commands/Economy/daily.js | 107 ---- src/commands/Economy/deposit.js | 144 ----- src/commands/Economy/eleaderboard.js | 94 --- src/commands/Economy/fish.js | 135 ----- src/commands/Economy/gamble.js | 136 ----- src/commands/Economy/inventory.js | 74 --- src/commands/Economy/mine.js | 98 --- src/commands/Economy/modules/shop_browse.js | 90 --- .../Economy/modules/shop_config_setrole.js | 36 -- src/commands/Economy/pay.js | 158 ----- src/commands/Economy/rob.js | 156 ----- src/commands/Economy/shop.js | 60 -- src/commands/Economy/slut.js | 193 ------ src/commands/Economy/withdraw.js | 86 --- src/commands/Economy/work.js | 127 ---- src/commands/Utility/wipedata.js | 4 - src/config/application.js | 8 - src/config/botConfig.js | 47 +- src/config/shop/index.js | 198 ------- src/config/shop/items.js | 234 -------- src/handlers/wipedataButtons.js | 19 +- src/services/economy.js | 306 ---------- src/services/economyService.js | 558 ------------------ src/services/shopService.js | 210 ------- src/services/utilityService.js | 15 - src/utils/economy.js | 480 --------------- 32 files changed, 29 insertions(+), 4262 deletions(-) delete mode 100644 src/commands/Economy/balance.js delete mode 100644 src/commands/Economy/beg.js delete mode 100644 src/commands/Economy/buy.js delete mode 100644 src/commands/Economy/crime.js delete mode 100644 src/commands/Economy/daily.js delete mode 100644 src/commands/Economy/deposit.js delete mode 100644 src/commands/Economy/eleaderboard.js delete mode 100644 src/commands/Economy/fish.js delete mode 100644 src/commands/Economy/gamble.js delete mode 100644 src/commands/Economy/inventory.js delete mode 100644 src/commands/Economy/mine.js delete mode 100644 src/commands/Economy/modules/shop_browse.js delete mode 100644 src/commands/Economy/modules/shop_config_setrole.js delete mode 100644 src/commands/Economy/pay.js delete mode 100644 src/commands/Economy/rob.js delete mode 100644 src/commands/Economy/shop.js delete mode 100644 src/commands/Economy/slut.js delete mode 100644 src/commands/Economy/withdraw.js delete mode 100644 src/commands/Economy/work.js delete mode 100644 src/config/shop/index.js delete mode 100644 src/config/shop/items.js delete mode 100644 src/services/economy.js delete mode 100644 src/services/economyService.js delete mode 100644 src/services/shopService.js delete mode 100644 src/utils/economy.js diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 74ab98f754..91712ff29a 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -11,7 +11,10 @@ "Bash(git rm *)", "Bash(git add *)", "Bash(git commit -m ' *)", - "Bash(git push *)" + "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\\\\\", \"\"\\) })" ] } } diff --git a/src/commands/Core/help.js b/src/commands/Core/help.js index 225b49a152..a96a020f49 100644 --- a/src/commands/Core/help.js +++ b/src/commands/Core/help.js @@ -55,17 +55,19 @@ export async function createInitialHelpMenu(client) { 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, - }; - }), + ...categoryDirs + .filter((category) => category.toLowerCase() !== 'economy') + .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"; @@ -81,11 +83,6 @@ export async function createInitialHelpMenu(client) { 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", @@ -135,6 +132,16 @@ export async function createInitialHelpMenu(client) { name: "๐Ÿ”ง **Utilities**", value: "Useful tools and server utilities", inline: true + }, + { + name: "๐Ÿ› ๏ธ **Tools**", + value: "Embed builder, polls, and other creation tools", + inline: true + }, + { + name: "๐Ÿ” **Search**", + value: "Search YouTube, Wikipedia, and more", + inline: true } ); 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 4833085345..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/Utility/wipedata.js b/src/commands/Utility/wipedata.js index 6aad2fb872..83c41235fe 100644 --- a/src/commands/Utility/wipedata.js +++ b/src/commands/Utility/wipedata.js @@ -15,12 +15,8 @@ export default { 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?**`; 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/botConfig.js b/src/config/botConfig.js index e212ceaf0c..9ee33afd95 100644 --- a/src/config/botConfig.js +++ b/src/config/botConfig.js @@ -28,6 +28,7 @@ export const botConfig = { { name: "/help", type: 3 }, { name: "zen vibes", type: 2 }, { name: "itay100k build this bot", type: 5 }, + ], }, @@ -120,7 +121,6 @@ export const botConfig = { closed: "#ED4245", pending: "#99AAB5", }, - economy: "#F1C40F", birthday: "#E91E63", moderation: "#9B59B6", @@ -149,50 +149,6 @@ export const botConfig = { }, }, - // ========================= - // 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 // ========================= @@ -428,7 +384,6 @@ export const botConfig = { // Set any feature to `false` to disable it globally. features: { // Core systems. - economy: true, leveling: true, moderation: true, logging: true, diff --git a/src/config/shop/index.js b/src/config/shop/index.js deleted file mode 100644 index 7a8a0d6e20..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/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/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/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/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/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' - } - ]; -} - - - From 597827fad980ba0c826c0e9a90c1e9bad6c60cd5 Mon Sep 17 00:00:00 2001 From: Itay100K Date: Fri, 1 May 2026 21:57:55 +0300 Subject: [PATCH 015/275] Add joke, meme, quote, github commands; remove cross-server commands - Add /joke with type filter (pun, programming, dark, misc) - Add /meme random meme from Reddit via meme-api.com - Add /quote inspirational quote via zenquotes.io - Add /github user and repo lookup via GitHub API - Remove CrossServer category (sendmessage, sendembed) - Restore /fakemessage to Fun category - Update /help embed and changelog to v2.2.0 Co-Authored-By: Claude Sonnet 4.6 --- src/commands/Core/help.js | 3 +- src/commands/Fun/joke.js | 55 ++++++++++++++++++++++ src/commands/Fun/meme.js | 48 +++++++++++++++++++ src/commands/Fun/quote.js | 40 ++++++++++++++++ src/commands/Search/github.js | 87 +++++++++++++++++++++++++++++++++++ src/config/changelog.js | 11 +++++ 6 files changed, 242 insertions(+), 2 deletions(-) create mode 100644 src/commands/Fun/joke.js create mode 100644 src/commands/Fun/meme.js create mode 100644 src/commands/Fun/quote.js create mode 100644 src/commands/Search/github.js diff --git a/src/commands/Core/help.js b/src/commands/Core/help.js index a96a020f49..d51837977c 100644 --- a/src/commands/Core/help.js +++ b/src/commands/Core/help.js @@ -23,7 +23,6 @@ const HELP_MENU_TIMEOUT_MS = 5 * 60 * 1000; const CATEGORY_ICONS = { Core: "โ„น๏ธ", Moderation: "๐Ÿ›ก๏ธ", - Economy: "๐Ÿ’ฐ", Fun: "๐ŸŽฎ", Leveling: "๐Ÿ“Š", Utility: "๐Ÿ”ง", @@ -140,7 +139,7 @@ export async function createInitialHelpMenu(client) { }, { name: "๐Ÿ” **Search**", - value: "Search YouTube, Wikipedia, and more", + value: "Search YouTube, Wikipedia, GitHub, and more", inline: true } ); diff --git a/src/commands/Fun/joke.js b/src/commands/Fun/joke.js new file mode 100644 index 0000000000..c048ca13b0 --- /dev/null +++ b/src/commands/Fun/joke.js @@ -0,0 +1,55 @@ +import { SlashCommandBuilder } from 'discord.js'; +import { createEmbed, errorEmbed } 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('joke') + .setDescription('Get a random joke') + .addStringOption(option => + option.setName('type').setDescription('Joke type').setRequired(false) + .addChoices( + { name: 'Any', value: 'Any' }, + { name: 'Pun', value: 'Pun' }, + { name: 'Programming', value: 'Programming' }, + { name: 'Misc', value: 'Misc' }, + { name: 'Dark', value: 'Dark' }, + ) + ), + + async execute(interaction) { + try { + await InteractionHelper.safeDefer(interaction); + + const type = interaction.options.getString('type') ?? 'Any'; + const res = await fetch(`https://v2.jokeapi.dev/joke/${type}?blacklistFlags=racist,sexist`); + + if (!res.ok) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Failed', 'Could not fetch a joke right now. Try again later.')], + }); + } + + const data = await res.json(); + const text = data.type === 'twopart' + ? `${data.setup}\n\n||${data.delivery}||` + : data.joke; + + const embed = createEmbed({ + title: `๐Ÿ˜‚ ${data.category} Joke`, + description: text, + color: 'yellow', + footer: { text: 'JokeAPI' }, + timestamp: false, + }); + + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); + logger.info(`Joke: ${interaction.user.id} got a ${data.category} joke`); + } catch (error) { + logger.error('Joke command error:', error); + await handleInteractionError(interaction, error, { commandName: 'joke' }); + } + }, +}; diff --git a/src/commands/Fun/meme.js b/src/commands/Fun/meme.js new file mode 100644 index 0000000000..9927a9badc --- /dev/null +++ b/src/commands/Fun/meme.js @@ -0,0 +1,48 @@ +import { SlashCommandBuilder } from 'discord.js'; +import { createEmbed, errorEmbed } 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('meme') + .setDescription('Get a random meme'), + + async execute(interaction) { + try { + await InteractionHelper.safeDefer(interaction); + + const res = await fetch('https://meme-api.com/gimme'); + + if (!res.ok) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Failed', 'Could not fetch a meme right now. Try again later.')], + }); + } + + const data = await res.json(); + + if (data.nsfw) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('NSFW', 'The fetched meme was NSFW. Try again!')], + }); + } + + const embed = createEmbed({ + title: data.title, + color: 'blurple', + footer: { text: `r/${data.subreddit} โ€ข ๐Ÿ‘ ${data.ups}` }, + timestamp: false, + }); + embed.setImage(data.url); + embed.setURL(data.postLink); + + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); + logger.info(`Meme: ${interaction.user.id} got meme from r/${data.subreddit}`); + } catch (error) { + logger.error('Meme command error:', error); + await handleInteractionError(interaction, error, { commandName: 'meme' }); + } + }, +}; diff --git a/src/commands/Fun/quote.js b/src/commands/Fun/quote.js new file mode 100644 index 0000000000..da544d09a0 --- /dev/null +++ b/src/commands/Fun/quote.js @@ -0,0 +1,40 @@ +import { SlashCommandBuilder } from 'discord.js'; +import { createEmbed, errorEmbed } 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('quote') + .setDescription('Get a random inspirational quote'), + + async execute(interaction) { + try { + await InteractionHelper.safeDefer(interaction); + + const res = await fetch('https://zenquotes.io/api/random'); + + if (!res.ok) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Failed', 'Could not fetch a quote right now. Try again later.')], + }); + } + + const [data] = await res.json(); + + const embed = createEmbed({ + description: `*"${data.q}"*\n\nโ€” **${data.a}**`, + color: 'blurple', + footer: { text: 'ZenQuotes.io' }, + timestamp: false, + }); + + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); + logger.info(`Quote: ${interaction.user.id} got a quote by ${data.a}`); + } catch (error) { + logger.error('Quote command error:', error); + await handleInteractionError(interaction, error, { commandName: 'quote' }); + } + }, +}; diff --git a/src/commands/Search/github.js b/src/commands/Search/github.js new file mode 100644 index 0000000000..d601cfe72e --- /dev/null +++ b/src/commands/Search/github.js @@ -0,0 +1,87 @@ +import { SlashCommandBuilder } from 'discord.js'; +import { createEmbed, errorEmbed } 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('github') + .setDescription('Look up a GitHub user or repository') + .addStringOption(option => + option.setName('query').setDescription('Username or user/repo (e.g. "torvalds" or "torvalds/linux")').setRequired(true) + ), + + async execute(interaction) { + try { + await InteractionHelper.safeDefer(interaction); + + const query = interaction.options.getString('query').trim(); + const isRepo = query.includes('/'); + const url = isRepo + ? `https://api.github.com/repos/${query}` + : `https://api.github.com/users/${query}`; + + const res = await fetch(url, { + headers: { 'User-Agent': 'itay100k-bot' }, + }); + + if (res.status === 404) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Not Found', `No GitHub ${isRepo ? 'repository' : 'user'} found for \`${query}\`.`)], + }); + } + + if (!res.ok) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Failed', 'Could not reach the GitHub API. Try again later.')], + }); + } + + const data = await res.json(); + let embed; + + if (isRepo) { + embed = createEmbed({ + title: data.full_name, + description: data.description || 'No description provided.', + color: 'blurple', + footer: { text: 'GitHub' }, + timestamp: false, + }); + embed.setURL(data.html_url); + embed.addFields( + { name: 'โญ Stars', value: data.stargazers_count.toLocaleString(), inline: true }, + { name: '๐Ÿด Forks', value: data.forks_count.toLocaleString(), inline: true }, + { name: '๐Ÿ‘€ Watchers', value: data.watchers_count.toLocaleString(), inline: true }, + { name: '๐Ÿ› Issues', value: data.open_issues_count.toLocaleString(), inline: true }, + { name: '๐Ÿ“ Language', value: data.language || 'Unknown', inline: true }, + { name: '๐Ÿ“„ License', value: data.license?.name || 'None', inline: true }, + ); + } else { + embed = createEmbed({ + title: data.name || data.login, + description: data.bio || 'No bio provided.', + color: 'blurple', + footer: { text: 'GitHub' }, + timestamp: false, + }); + embed.setURL(data.html_url); + embed.setThumbnail(data.avatar_url); + embed.addFields( + { name: '๐Ÿ‘ฅ Followers', value: data.followers.toLocaleString(), inline: true }, + { name: 'โžก๏ธ Following', value: data.following.toLocaleString(), inline: true }, + { name: '๐Ÿ“ฆ Repos', value: data.public_repos.toLocaleString(), inline: true }, + ); + if (data.location) embed.addFields({ name: '๐Ÿ“ Location', value: data.location, inline: true }); + if (data.company) embed.addFields({ name: '๐Ÿข Company', value: data.company, inline: true }); + } + + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); + logger.info(`GitHub: ${interaction.user.id} looked up ${query}`); + } catch (error) { + logger.error('GitHub command error:', error); + await handleInteractionError(interaction, error, { commandName: 'github' }); + } + }, +}; diff --git a/src/config/changelog.js b/src/config/changelog.js index 6eb374961d..e7bf094286 100644 --- a/src/config/changelog.js +++ b/src/config/changelog.js @@ -1,4 +1,15 @@ export const changelog = [ + { + 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', From 47cbce25f0e18d52e77c5c52cb2a7d8eed7c34d1 Mon Sep 17 00:00:00 2001 From: Itay100K Date: Sat, 2 May 2026 10:57:29 +0300 Subject: [PATCH 016/275] Add ? prefix commands and update changelog to v2.3.0 - Add prefix command handler to messageCreate event - Supports: ?joke, ?meme, ?quote, ?flip, ?roll, ?avatar, ?fact, ?github, ?help - Changelog bumped to v2.3.0 Co-Authored-By: Claude Sonnet 4.6 --- src/config/changelog.js | 8 +++ src/events/messageCreate.js | 133 +++++++++++++++++++++++++++++++++++- 2 files changed, 139 insertions(+), 2 deletions(-) diff --git a/src/config/changelog.js b/src/config/changelog.js index e7bf094286..8fbe4aff20 100644 --- a/src/config/changelog.js +++ b/src/config/changelog.js @@ -1,4 +1,12 @@ export const changelog = [ + { + 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', diff --git a/src/events/messageCreate.js b/src/events/messageCreate.js index b7f9841dc4..c6812bcb15 100644 --- a/src/events/messageCreate.js +++ b/src/events/messageCreate.js @@ -3,7 +3,7 @@ -import { Events } from 'discord.js'; +import { Events, EmbedBuilder } from 'discord.js'; import { logger } from '../utils/logger.js'; import { getLevelingConfig, getUserLevelData } from '../services/leveling.js'; import { addXp } from '../services/xpSystem.js'; @@ -11,14 +11,19 @@ import { checkRateLimit } from '../utils/rateLimiter.js'; const MESSAGE_XP_RATE_LIMIT_ATTEMPTS = 12; const MESSAGE_XP_RATE_LIMIT_WINDOW_MS = 10000; +const PREFIX = '?'; export default { name: Events.MessageCreate, async execute(message, client) { try { - if (message.author.bot || !message.guild) return; + if (message.content.startsWith(PREFIX)) { + await handlePrefixCommand(message, client); + return; + } + await handleLeveling(message, client); } catch (error) { logger.error('Error in messageCreate event:', error); @@ -33,6 +38,130 @@ export default { +async function handlePrefixCommand(message, client) { + const args = message.content.slice(PREFIX.length).trim().split(/\s+/); + const command = args.shift().toLowerCase(); + + try { + switch (command) { + case 'joke': { + const type = args[0] ? args[0].charAt(0).toUpperCase() + args[0].slice(1).toLowerCase() : 'Any'; + const valid = ['Any', 'Pun', 'Programming', 'Misc', 'Dark']; + const category = valid.includes(type) ? type : 'Any'; + const res = await fetch(`https://v2.jokeapi.dev/joke/${category}?blacklistFlags=racist,sexist`); + if (!res.ok) return message.reply('Could not fetch a joke right now.'); + const data = await res.json(); + const text = data.type === 'twopart' ? `${data.setup}\n\n||${data.delivery}||` : data.joke; + const embed = new EmbedBuilder().setTitle(`๐Ÿ˜‚ ${data.category} Joke`).setDescription(text).setColor(0xFEE75C); + return message.reply({ embeds: [embed] }); + } + + case 'meme': { + const res = await fetch('https://meme-api.com/gimme'); + if (!res.ok) return message.reply('Could not fetch a meme right now.'); + const data = await res.json(); + if (data.nsfw) return message.reply('Got an NSFW meme, try again!'); + const embed = new EmbedBuilder() + .setTitle(data.title).setURL(data.postLink) + .setImage(data.url).setColor(0x5865F2) + .setFooter({ text: `r/${data.subreddit} โ€ข ๐Ÿ‘ ${data.ups}` }); + return message.reply({ embeds: [embed] }); + } + + case 'quote': { + const res = await fetch('https://zenquotes.io/api/random'); + if (!res.ok) return message.reply('Could not fetch a quote right now.'); + const [data] = await res.json(); + const embed = new EmbedBuilder() + .setDescription(`*"${data.q}"*\n\nโ€” **${data.a}**`) + .setColor(0x5865F2).setFooter({ text: 'ZenQuotes.io' }); + return message.reply({ embeds: [embed] }); + } + + case 'flip': { + const result = Math.random() < 0.5 ? '๐Ÿช™ Heads' : '๐Ÿช™ Tails'; + return message.reply(result); + } + + case 'roll': { + const sides = parseInt(args[0]) || 6; + if (sides < 2 || sides > 10000) return message.reply('Please provide a number between 2 and 10,000.'); + const roll = Math.floor(Math.random() * sides) + 1; + return message.reply(`๐ŸŽฒ You rolled a **${roll}** (1โ€“${sides})`); + } + + case 'avatar': { + const mentioned = message.mentions.users.first(); + const target = mentioned || message.author; + const embed = new EmbedBuilder() + .setTitle(`${target.username}'s Avatar`) + .setImage(target.displayAvatarURL({ size: 1024 })) + .setColor(0x5865F2); + return message.reply({ embeds: [embed] }); + } + + case 'fact': { + const res = await fetch('https://uselessfacts.jsph.pl/api/v2/facts/random?language=en'); + if (!res.ok) return message.reply('Could not fetch a fact right now.'); + const data = await res.json(); + const embed = new EmbedBuilder().setTitle('๐Ÿ’ก Random Fact').setDescription(data.text).setColor(0x3498DB); + return message.reply({ embeds: [embed] }); + } + + case 'github': { + if (!args[0]) return message.reply('Usage: `?github ` or `?github `'); + const query = args[0]; + const isRepo = query.includes('/'); + const url = isRepo ? `https://api.github.com/repos/${query}` : `https://api.github.com/users/${query}`; + const res = await fetch(url, { headers: { 'User-Agent': 'itay100k-bot' } }); + if (res.status === 404) return message.reply(`No GitHub ${isRepo ? 'repository' : 'user'} found for \`${query}\`.`); + if (!res.ok) return message.reply('Could not reach the GitHub API.'); + const data = await res.json(); + const embed = new EmbedBuilder().setColor(0x5865F2).setURL(data.html_url).setFooter({ text: 'GitHub' }); + if (isRepo) { + embed.setTitle(data.full_name).setDescription(data.description || 'No description.') + .addFields( + { name: 'โญ Stars', value: String(data.stargazers_count), inline: true }, + { name: '๐Ÿด Forks', value: String(data.forks_count), inline: true }, + { name: '๐Ÿ“ Language', value: data.language || 'Unknown', inline: true }, + ); + } else { + embed.setTitle(data.name || data.login).setDescription(data.bio || 'No bio.') + .setThumbnail(data.avatar_url) + .addFields( + { name: '๐Ÿ‘ฅ Followers', value: String(data.followers), inline: true }, + { name: '๐Ÿ“ฆ Repos', value: String(data.public_repos), inline: true }, + ); + } + return message.reply({ embeds: [embed] }); + } + + case 'help': { + const embed = new EmbedBuilder() + .setTitle('? Prefix Commands') + .setDescription([ + '`?joke [pun/programming/dark/misc]` โ€” Random joke', + '`?meme` โ€” Random meme', + '`?quote` โ€” Inspirational quote', + '`?flip` โ€” Coin flip', + '`?roll [sides]` โ€” Roll a dice (default: 6)', + '`?avatar [@user]` โ€” Show avatar', + '`?fact` โ€” Random fact', + '`?github ` โ€” GitHub lookup', + ].join('\n')) + .setColor(0x5865F2); + return message.reply({ embeds: [embed] }); + } + + default: + break; + } + } catch (err) { + logger.error(`Prefix command error (${command}):`, err); + message.reply('Something went wrong. Try again later.').catch(() => {}); + } +} + async function handleLeveling(message, client) { try { const rateLimitKey = `xp-event:${message.guild.id}:${message.author.id}`; From 225c749e77d7540889696545ec28428195fc4094 Mon Sep 17 00:00:00 2001 From: Itay100K Date: Sat, 2 May 2026 11:10:36 +0300 Subject: [PATCH 017/275] Add autoresponder and temprole commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /autoresponder add/remove/list/clear โ€” auto-reply to trigger words/phrases with contains, exact, or starts-with matching; supports {user} placeholder - /temprole add/remove/list โ€” assign a role with a duration (30m/2h/1d/1w) that auto-expires via a per-minute cron job - messageCreate now checks autoresponders for every non-prefix message - Changelog updated to v2.4.0 Co-Authored-By: Claude Sonnet 4.6 --- src/app.js | 2 + src/commands/Moderation/autoresponder.js | 145 ++++++++++++++++++++ src/commands/Moderation/temprole.js | 165 +++++++++++++++++++++++ src/config/changelog.js | 8 ++ src/events/messageCreate.js | 2 + src/services/autoresponderService.js | 79 +++++++++++ src/services/tempRoleService.js | 67 +++++++++ 7 files changed, 468 insertions(+) create mode 100644 src/commands/Moderation/autoresponder.js create mode 100644 src/commands/Moderation/temprole.js create mode 100644 src/services/autoresponderService.js create mode 100644 src/services/tempRoleService.js diff --git a/src/app.js b/src/app.js index 328cb4a762..5d9db52356 100644 --- a/src/app.js +++ b/src/app.js @@ -11,6 +11,7 @@ import { getServerCounters, saveServerCounters, updateCounter } from './services import { logger, startupLog, shutdownLog } from './utils/logger.js'; import { checkBirthdays } from './services/birthdayService.js'; import { checkGiveaways } from './services/giveawayService.js'; +import { checkTempRoles } from './services/tempRoleService.js'; import { loadCommands, registerCommands as registerSlashCommands } from './handlers/commandLoader.js'; class TitanBot extends Client { @@ -230,6 +231,7 @@ class TitanBot extends Client { setupCronJobs() { cron.schedule('0 6 * * *', () => checkBirthdays(this)); cron.schedule('* * * * *', () => checkGiveaways(this)); + cron.schedule('* * * * *', () => checkTempRoles(this)); cron.schedule('*/15 * * * *', () => this.updateAllCounters()); } diff --git a/src/commands/Moderation/autoresponder.js b/src/commands/Moderation/autoresponder.js new file mode 100644 index 0000000000..440aec2b05 --- /dev/null +++ b/src/commands/Moderation/autoresponder.js @@ -0,0 +1,145 @@ +import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; +import { createEmbed, errorEmbed, successEmbed } from '../../utils/embeds.js'; +import { logger } from '../../utils/logger.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { AutoresponderService } from '../../services/autoresponderService.js'; + +export default { + data: new SlashCommandBuilder() + .setName('autoresponder') + .setDescription('Manage automatic responses to trigger words or phrases') + .addSubcommand(sub => + sub.setName('add') + .setDescription('Add a trigger and its response') + .addStringOption(o => + o.setName('trigger').setDescription('Word or phrase that triggers the response').setRequired(true) + ) + .addStringOption(o => + o.setName('response').setDescription('What the bot replies (use {user} for a mention)').setRequired(true) + ) + .addStringOption(o => + o.setName('match') + .setDescription('How to match the trigger (default: contains)') + .setRequired(false) + .addChoices( + { name: 'Contains (anywhere in message)', value: 'contains' }, + { name: 'Exact (whole message only)', value: 'exact' }, + { name: 'Starts with', value: 'startswith' }, + ) + ) + ) + .addSubcommand(sub => + sub.setName('remove') + .setDescription('Remove a trigger by its text') + .addStringOption(o => + o.setName('trigger').setDescription('The trigger text to remove').setRequired(true) + ) + ) + .addSubcommand(sub => + sub.setName('list') + .setDescription('List all autoresponders in this server') + ) + .addSubcommand(sub => + sub.setName('clear') + .setDescription('Remove ALL autoresponders from this server') + ) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages), + category: 'moderation', + + async execute(interaction, config, client) { + await InteractionHelper.safeDefer(interaction); + + const sub = interaction.options.getSubcommand(); + const guildId = interaction.guildId; + + try { + if (sub === 'add') { + const trigger = interaction.options.getString('trigger').toLowerCase().trim(); + const response = interaction.options.getString('response').trim(); + const matchType = interaction.options.getString('match') ?? 'contains'; + + if (trigger.length > 100) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Too Long', 'Trigger must be 100 characters or fewer.')], + }); + } + if (response.length > 500) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Too Long', 'Response must be 500 characters or fewer.')], + }); + } + + const result = await AutoresponderService.add(client, guildId, { + trigger, response, matchType, createdBy: interaction.user.id, + }); + + if (!result.success) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Failed', result.error || 'Could not add autoresponder.')], + }); + } + + return InteractionHelper.safeEditReply(interaction, { + embeds: [successEmbed( + 'Autoresponder Added', + `**Trigger:** \`${trigger}\`\n**Response:** ${response}\n**Match:** ${matchType}\n**ID:** \`${result.id}\``, + )], + }); + } + + if (sub === 'remove') { + const trigger = interaction.options.getString('trigger').toLowerCase().trim(); + const result = await AutoresponderService.remove(client, guildId, trigger); + + if (!result.success) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Not Found', `No autoresponder found for \`${trigger}\`.`)], + }); + } + + return InteractionHelper.safeEditReply(interaction, { + embeds: [successEmbed('Autoresponder Removed', `Removed trigger: \`${trigger}\``)], + }); + } + + if (sub === 'list') { + const triggers = await AutoresponderService.list(client, guildId); + + if (!triggers.length) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [createEmbed({ + title: 'Autoresponders', + description: 'No autoresponders set up. Use `/autoresponder add` to create one.', + color: 'blue', + })], + }); + } + + const lines = triggers.map((t, i) => { + const preview = t.response.length > 60 ? t.response.slice(0, 60) + 'โ€ฆ' : t.response; + return `**${i + 1}.** \`${t.trigger}\` โ†’ ${preview} *(${t.matchType})*`; + }).join('\n'); + + return InteractionHelper.safeEditReply(interaction, { + embeds: [createEmbed({ + title: `Autoresponders (${triggers.length})`, + description: lines, + color: 'blue', + })], + }); + } + + if (sub === 'clear') { + await AutoresponderService.clear(client, guildId); + return InteractionHelper.safeEditReply(interaction, { + embeds: [successEmbed('Cleared', 'All autoresponders have been removed.')], + }); + } + } catch (error) { + logger.error('Autoresponder command error:', error); + await InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Error', 'Something went wrong. Try again later.')], + }); + } + }, +}; diff --git a/src/commands/Moderation/temprole.js b/src/commands/Moderation/temprole.js new file mode 100644 index 0000000000..f535e9a2ea --- /dev/null +++ b/src/commands/Moderation/temprole.js @@ -0,0 +1,165 @@ +import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; +import { createEmbed, errorEmbed, successEmbed } from '../../utils/embeds.js'; +import { logger } from '../../utils/logger.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { TempRoleService } from '../../services/tempRoleService.js'; + +export default { + data: new SlashCommandBuilder() + .setName('temprole') + .setDescription('Assign a role that automatically expires after a set duration') + .addSubcommand(sub => + sub.setName('add') + .setDescription('Give a user a temporary role') + .addUserOption(o => + o.setName('user').setDescription('User to assign the role to').setRequired(true) + ) + .addRoleOption(o => + o.setName('role').setDescription('Role to assign').setRequired(true) + ) + .addStringOption(o => + o.setName('duration').setDescription('Duration e.g. 30m, 2h, 1d, 1w (max 30 days)').setRequired(true) + ) + ) + .addSubcommand(sub => + sub.setName('remove') + .setDescription('Remove a temporary role from a user early') + .addUserOption(o => + o.setName('user').setDescription('User to remove the role from').setRequired(true) + ) + .addRoleOption(o => + o.setName('role').setDescription('Role to remove').setRequired(true) + ) + ) + .addSubcommand(sub => + sub.setName('list') + .setDescription('List all active temporary roles in this server') + ) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageRoles), + category: 'moderation', + + async execute(interaction, config, client) { + await InteractionHelper.safeDefer(interaction); + + const sub = interaction.options.getSubcommand(); + const guildId = interaction.guildId; + + try { + if (sub === 'add') { + const target = interaction.options.getMember('user'); + const role = interaction.options.getRole('role'); + const durationStr = interaction.options.getString('duration'); + const durationMs = parseDuration(durationStr); + + if (!target) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Not Found', 'That user is not in this server.')], + }); + } + if (!durationMs) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Invalid Duration', 'Use formats like `30m`, `2h`, `1d`, `1w`. Max 30 days.')], + }); + } + if (durationMs > 30 * 24 * 60 * 60 * 1000) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Too Long', 'Maximum duration is 30 days.')], + }); + } + + const botMember = interaction.guild.members.me; + if (botMember.roles.highest.comparePositionTo(role) <= 0) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Permission Error', "That role is above my highest role โ€” I can't assign it.")], + }); + } + + await target.roles.add(role, `Temp role assigned by ${interaction.user.tag}`); + + const expiresAt = Date.now() + durationMs; + await TempRoleService.add(client, guildId, { + userId: target.id, roleId: role.id, expiresAt, assignedBy: interaction.user.id, + }); + + return InteractionHelper.safeEditReply(interaction, { + embeds: [successEmbed( + 'Temp Role Assigned', + `${target} now has ${role} for **${durationStr}**.\nExpires: `, + )], + }); + } + + if (sub === 'remove') { + const target = interaction.options.getMember('user'); + const role = interaction.options.getRole('role'); + + if (!target) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Not Found', 'That user is not in this server.')], + }); + } + + const result = await TempRoleService.remove(client, guildId, target.id, role.id); + if (!result.success) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Not Found', 'No active temp role found for that user and role combination.')], + }); + } + + await target.roles.remove(role, `Temp role removed early by ${interaction.user.tag}`).catch(() => {}); + + return InteractionHelper.safeEditReply(interaction, { + embeds: [successEmbed('Temp Role Removed', `Removed ${role} from ${target} early.`)], + }); + } + + if (sub === 'list') { + const entries = await TempRoleService.list(client, guildId); + + if (!entries.length) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [createEmbed({ + title: 'Active Temp Roles', + description: 'No active temp roles. Use `/temprole add` to create one.', + color: 'blue', + })], + }); + } + + const lines = entries.map(e => { + const expiry = Math.floor(e.expiresAt / 1000); + return `<@${e.userId}> โ†’ <@&${e.roleId}> โ€” expires `; + }).join('\n'); + + return InteractionHelper.safeEditReply(interaction, { + embeds: [createEmbed({ + title: `Active Temp Roles (${entries.length})`, + description: lines, + color: 'blue', + })], + }); + } + } catch (error) { + logger.error('Temprole command error:', error); + await InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Error', 'Something went wrong. Try again later.')], + }); + } + }, +}; + +function parseDuration(str) { + const match = str.trim().match(/^(\d+)\s*(s|sec|m|min|h|hr|d|day|w|wk|week)s?$/i); + if (!match) return null; + const num = parseInt(match[1]); + if (num <= 0) return null; + const unit = match[2].toLowerCase(); + const map = { + s: 1000, sec: 1000, + m: 60000, min: 60000, + h: 3600000, hr: 3600000, + d: 86400000, day: 86400000, + w: 604800000, wk: 604800000, week: 604800000, + }; + return map[unit] != null ? num * map[unit] : null; +} diff --git a/src/config/changelog.js b/src/config/changelog.js index 8fbe4aff20..835f2672e2 100644 --- a/src/config/changelog.js +++ b/src/config/changelog.js @@ -1,4 +1,12 @@ export const changelog = [ + { + 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', diff --git a/src/events/messageCreate.js b/src/events/messageCreate.js index c6812bcb15..6f6cb10733 100644 --- a/src/events/messageCreate.js +++ b/src/events/messageCreate.js @@ -8,6 +8,7 @@ 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'; +import { AutoresponderService } from '../services/autoresponderService.js'; const MESSAGE_XP_RATE_LIMIT_ATTEMPTS = 12; const MESSAGE_XP_RATE_LIMIT_WINDOW_MS = 10000; @@ -24,6 +25,7 @@ export default { return; } + await AutoresponderService.check(client, message); await handleLeveling(message, client); } catch (error) { logger.error('Error in messageCreate event:', error); 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/tempRoleService.js b/src/services/tempRoleService.js new file mode 100644 index 0000000000..e3917b8712 --- /dev/null +++ b/src/services/tempRoleService.js @@ -0,0 +1,67 @@ +import { logger } from '../utils/logger.js'; +import { randomUUID } from 'crypto'; + +function getKey(guildId) { + return `guild:${guildId}:temproles`; +} + +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)); + filtered.push({ + id: randomUUID().slice(0, 8), + userId, + roleId, + expiresAt, + assignedBy, + assignedAt: Date.now(), + }); + await client.db.set(getKey(guildId), filtered); + return { success: true }; + }, + + 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)); + }, +}; + +export async function checkTempRoles(client) { + const now = Date.now(); + for (const [guildId, guild] of client.guilds.cache) { + try { + const entries = await client.db.get(`guild:${guildId}:temproles`, []); + 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 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 check error for guild ${guildId}:`, error); + } + } +} From 5cd7624cb3768382ecac5e1844ac728d981364d1 Mon Sep 17 00:00:00 2001 From: Itay100K Date: Sat, 2 May 2026 11:25:19 +0300 Subject: [PATCH 018/275] Fix temprole: use setTimeout for precise on-time removal Previously roles were only removed by a per-minute cron job, causing up to 60s delay. Now a setTimeout is scheduled at the exact expiry time when a role is assigned. On bot restart, loadAndScheduleTempRoles reschedules all active temp roles from the DB. The cron job stays as a fallback for durations over 24 days. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/Moderation/temprole.js | 5 ++- src/events/ready.js | 3 ++ src/services/tempRoleService.js | 62 ++++++++++++++++++++++++++--- 3 files changed, 62 insertions(+), 8 deletions(-) diff --git a/src/commands/Moderation/temprole.js b/src/commands/Moderation/temprole.js index f535e9a2ea..e6ec7d8396 100644 --- a/src/commands/Moderation/temprole.js +++ b/src/commands/Moderation/temprole.js @@ -2,7 +2,7 @@ import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; import { createEmbed, errorEmbed, successEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { TempRoleService } from '../../services/tempRoleService.js'; +import { TempRoleService, scheduleTempRoleRemoval } from '../../services/tempRoleService.js'; export default { data: new SlashCommandBuilder() @@ -77,9 +77,10 @@ export default { await target.roles.add(role, `Temp role assigned by ${interaction.user.tag}`); const expiresAt = Date.now() + durationMs; - await TempRoleService.add(client, guildId, { + const { entry } = await TempRoleService.add(client, guildId, { userId: target.id, roleId: role.id, expiresAt, assignedBy: interaction.user.id, }); + scheduleTempRoleRemoval(client, guildId, entry); return InteractionHelper.safeEditReply(interaction, { embeds: [successEmbed( diff --git a/src/events/ready.js b/src/events/ready.js index 69685267db..7d02825f48 100644 --- a/src/events/ready.js +++ b/src/events/ready.js @@ -2,6 +2,7 @@ import { Events } from "discord.js"; import { logger, startupLog } from "../utils/logger.js"; import config from "../config/application.js"; import { reconcileReactionRoleMessages } from "../services/reactionRoleService.js"; +import { loadAndScheduleTempRoles } from "../services/tempRoleService.js"; export default { name: Events.ClientReady, @@ -40,6 +41,8 @@ export default { startupLog( `Reaction role reconciliation: scanned ${reconciliationSummary.scannedMessages}, removed ${reconciliationSummary.removedMessages}, errors ${reconciliationSummary.errors}` ); + + await loadAndScheduleTempRoles(client); } catch (error) { logger.error("Error in ready event:", error); } diff --git a/src/services/tempRoleService.js b/src/services/tempRoleService.js index e3917b8712..77ae8967fc 100644 --- a/src/services/tempRoleService.js +++ b/src/services/tempRoleService.js @@ -1,10 +1,41 @@ 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), []); @@ -13,16 +44,17 @@ export const TempRoleService = { 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)); - filtered.push({ + 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 }; + return { success: true, entry }; }, async remove(client, guildId, userId, roleId) { @@ -40,11 +72,29 @@ export const TempRoleService = { }, }; +// 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(`guild:${guildId}:temproles`, []); + const entries = await client.db.get(getKey(guildId), []); const expired = entries.filter(e => e.expiresAt <= now); if (!expired.length) continue; @@ -53,7 +103,7 @@ export async function checkTempRoles(client) { 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}`); + 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); @@ -61,7 +111,7 @@ export async function checkTempRoles(client) { await TempRoleService.removeById(client, guildId, entry.id); } } catch (error) { - logger.error(`Temp role check error for guild ${guildId}:`, error); + logger.error(`Temp role cron check error for guild ${guildId}:`, error); } } } From 83b835908bdbb2078ee42e8f7430cf1ed3d2073e Mon Sep 17 00:00:00 2001 From: Itay100K Date: Sat, 2 May 2026 11:35:47 +0300 Subject: [PATCH 019/275] Filter /help to show only commands the user can use - canMemberUseCommand() checks default_member_permissions against the member's actual permissions (Administrators always see everything) - getAccessibleCategories() builds a set of categories with at least one accessible command for the member - /help embed and dropdown now only show categories the user can access - Category view and All Commands view filter out restricted commands - Back button and pagination also respect permissions Co-Authored-By: Claude Sonnet 4.6 --- src/commands/Core/help.js | 188 +++++++++++--------------------- src/handlers/helpButtons.js | 4 +- src/handlers/helpSelectMenus.js | 67 +++++++++--- 3 files changed, 122 insertions(+), 137 deletions(-) diff --git a/src/commands/Core/help.js b/src/commands/Core/help.js index d51837977c..041c3d60a4 100644 --- a/src/commands/Core/help.js +++ b/src/commands/Core/help.js @@ -1,4 +1,4 @@ -๏ปฟimport { +import { SlashCommandBuilder, ActionRowBuilder, ButtonBuilder, @@ -6,9 +6,8 @@ } from "discord.js"; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { createEmbed } from "../../utils/embeds.js"; -import { - createSelectMenu, -} from "../../utils/components.js"; +import { createSelectMenu } from "../../utils/components.js"; +import { getAccessibleCategories } from "../../handlers/helpSelectMenus.js"; import fs from "fs/promises"; import path from "path"; import { fileURLToPath } from "url"; @@ -20,26 +19,27 @@ const CATEGORY_SELECT_ID = "help-category-select"; const ALL_COMMANDS_ID = "help-all-commands"; const HELP_MENU_TIMEOUT_MS = 5 * 60 * 1000; -const CATEGORY_ICONS = { - Core: "โ„น๏ธ", - Moderation: "๐Ÿ›ก๏ธ", - Fun: "๐ŸŽฎ", - Leveling: "๐Ÿ“Š", - Utility: "๐Ÿ”ง", - Ticket: "๐ŸŽซ", - Welcome: "๐Ÿ‘‹", - Giveaway: "๐ŸŽ‰", - Tools: "๐Ÿ› ๏ธ", - Search: "๐Ÿ”", - Reaction_Roles: "๐ŸŽญ", - Community: "๐Ÿ‘ฅ", +const CATEGORY_INFO = { + Core: { icon: "โ„น๏ธ", desc: "Core bot commands and information" }, + Moderation: { icon: "๐Ÿ›ก๏ธ", desc: "Server moderation, user management, and enforcement tools" }, + Fun: { icon: "๐ŸŽฎ", desc: "Games, entertainment, and interactive commands" }, + Leveling: { icon: "๐Ÿ“Š", desc: "User levels, XP system, and progression tracking" }, + Utility: { icon: "๐Ÿ”ง", desc: "Useful tools and server utilities" }, + Ticket: { icon: "๐ŸŽซ", desc: "Support ticket system for server management" }, + Welcome: { icon: "๐Ÿ‘‹", desc: "Member welcome messages and onboarding" }, + Giveaway: { icon: "๐ŸŽ‰", desc: "Automated giveaway management and distribution" }, + Tools: { icon: "๐Ÿ› ๏ธ", desc: "Embed builder, polls, and other creation tools" }, + Search: { icon: "๐Ÿ”", desc: "Search YouTube, Wikipedia, GitHub, and more" }, + Reaction_roles: { icon: "๐ŸŽญ", desc: "Self-assignable roles using reaction-role systems" }, + Community: { icon: "๐Ÿ‘ฅ", desc: "Community tools, applications, and member engagement" }, + Jointocreate: { icon: "๐ŸŽ™๏ธ", desc: "Dynamic voice channel creation and management" }, + Verification: { icon: "โœ…", desc: "Member verification workflows and access gating" }, + Serverstats: { icon: "๐Ÿ“ˆ", desc: "Server statistics and display counters" }, + Logging: { icon: "๐Ÿ“‹", desc: "Server event logging and audit trails" }, + Voice: { icon: "๐Ÿ”Š", desc: "Voice channel commands and activities" }, }; - - - - -export async function createInitialHelpMenu(client) { +export async function createInitialHelpMenu(client, member) { const commandsPath = path.join(__dirname, "../../commands"); const categoryDirs = ( await fs.readdir(commandsPath, { withFileTypes: true }) @@ -48,105 +48,57 @@ export async function createInitialHelpMenu(client) { .map((dirent) => dirent.name) .sort(); + // Build the set of categories this member has access to + const accessibleCategories = getAccessibleCategories(client, member); + + const filteredCategories = categoryDirs.filter((category) => { + if (category.toLowerCase() === 'economy') return false; + if (!accessibleCategories) return true; // no member (DM) โ†’ show all + return accessibleCategories.has(category); + }); + const options = [ { label: "๐Ÿ“‹ All Commands", - description: "View all available commands with pagination", + description: "View all commands available to you", value: ALL_COMMANDS_ID, }, - ...categoryDirs - .filter((category) => category.toLowerCase() !== 'economy') - .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, - }; - }), + ...filteredCategories.map((category) => { + const categoryName = + category.charAt(0).toUpperCase() + category.slice(1).toLowerCase(); + const info = CATEGORY_INFO[categoryName] || { icon: "๐Ÿ”", desc: `Commands in the ${categoryName} category` }; + return { + label: `${info.icon} ${categoryName}`, + description: `View ${categoryName.toLowerCase()} commands`, + value: category, + }; + }), ]; const botName = client?.user?.username || "Bot"; - const embed = createEmbed({ + const embed = createEmbed({ title: `๐Ÿค– ${botName} Help Center`, - description: "Your all-in-one Discord companion for moderation, economy, fun, and server management.", - color: 'primary' + description: "Showing commands available based on your permissions. Select a category to explore.", + color: 'primary', }); - embed.addFields( - { - name: "๐Ÿ›ก๏ธ **Moderation**", - value: "Server moderation, user management, and enforcement tools", - 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: "๐Ÿ‘ฅ **Community**", - value: "Community tools, applications, and member engagement", - 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 - }, - { - name: "๐Ÿ› ๏ธ **Tools**", - value: "Embed builder, polls, and other creation tools", - inline: true - }, - { - name: "๐Ÿ” **Search**", - value: "Search YouTube, Wikipedia, GitHub, and more", - inline: true - } - ); - - embed.setFooter({ - text: "Made with โค๏ธ" + // Dynamically add fields only for categories this member can access + const fields = filteredCategories.map((category) => { + const categoryName = + category.charAt(0).toUpperCase() + category.slice(1).toLowerCase(); + const info = CATEGORY_INFO[categoryName] || { icon: "๐Ÿ”", desc: `Commands in the ${categoryName} category` }; + return { + name: `${info.icon} **${categoryName}**`, + value: info.desc, + inline: true, + }; }); + + if (fields.length > 0) { + embed.addFields(fields); + } + + embed.setFooter({ text: "Made with โค๏ธ" }); embed.setTimestamp(); const supportButton = new ButtonBuilder() @@ -160,9 +112,7 @@ export async function createInitialHelpMenu(client) { options, ); - const buttonRow = new ActionRowBuilder().addComponents([ - supportButton, - ]); + const buttonRow = new ActionRowBuilder().addComponents([supportButton]); return { embeds: [embed], @@ -173,14 +123,12 @@ export async function createInitialHelpMenu(client) { export default { data: new SlashCommandBuilder() .setName("help") - .setDescription("Displays the help menu with all available commands"), + .setDescription("Displays commands available to you based on your permissions"), async execute(interaction, guildConfig, client) { - - const { MessageFlags } = await import('discord.js'); await InteractionHelper.safeDefer(interaction); - - const { embeds, components } = await createInitialHelpMenu(client); + + const { embeds, components } = await createInitialHelpMenu(client, interaction.member); await InteractionHelper.safeEditReply(interaction, { embeds, @@ -199,11 +147,7 @@ export default { embeds: [closedEmbed], components: [], }); - } catch (error) { - - } + } catch (_) {} }, HELP_MENU_TIMEOUT_MS); }, }; - - diff --git a/src/handlers/helpButtons.js b/src/handlers/helpButtons.js index 07bc460115..0ad7f4ff64 100644 --- a/src/handlers/helpButtons.js +++ b/src/handlers/helpButtons.js @@ -14,7 +14,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, @@ -92,7 +92,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..51e4c6c801 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, 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)); } } @@ -162,8 +203,8 @@ async function createCategoryCommandsMenu(category, client) { 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.` + ? `Commands available to you:` + : `You don't have permission to use any commands in the **${categoryName}** category.` }); if (categoryCommands.length > 0) { @@ -228,7 +269,7 @@ async function createCategoryCommandsMenu(category, client) { }; } -export async function createAllCommandsMenu(page = 1, client) { +export async function createAllCommandsMenu(page = 1, client, member) { const commandsPerPage = 45; const allCommands = []; @@ -264,6 +305,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 +344,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 +415,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 +452,3 @@ export const helpCategorySelectMenu = { } }, }; - - - - From a01ed3e6503e161ac288f49405bf00a207108252 Mon Sep 17 00:00:00 2001 From: Itay100K Date: Sat, 2 May 2026 11:59:54 +0300 Subject: [PATCH 020/275] Add /deleteserver command with confirmation prompt Owner-only command that shows a Cancel / Yes delete buttons before permanently deleting the guild. Times out after 30 seconds. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/Moderation/deleteserver.js | 94 +++++++++++++++++++++++++ src/config/changelog.js | 7 ++ 2 files changed, 101 insertions(+) create mode 100644 src/commands/Moderation/deleteserver.js diff --git a/src/commands/Moderation/deleteserver.js b/src/commands/Moderation/deleteserver.js new file mode 100644 index 0000000000..a6ff6eea60 --- /dev/null +++ b/src/commands/Moderation/deleteserver.js @@ -0,0 +1,94 @@ +import { + SlashCommandBuilder, + PermissionFlagsBits, + ButtonBuilder, + ButtonStyle, + ActionRowBuilder, + MessageFlags, +} from 'discord.js'; +import { createEmbed, errorEmbed } from '../../utils/embeds.js'; + +export default { + data: new SlashCommandBuilder() + .setName('deleteserver') + .setDescription('Permanently delete this server โ€” this cannot be undone') + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), + category: 'moderation', + + async execute(interaction) { + if (interaction.user.id !== interaction.guild.ownerId) { + return interaction.reply({ + embeds: [errorEmbed('Not Allowed', 'Only the server owner can delete the server.')], + flags: MessageFlags.Ephemeral, + }); + } + + const confirmId = `deleteserver-confirm-${interaction.id}`; + const cancelId = `deleteserver-cancel-${interaction.id}`; + + const embed = createEmbed({ + title: 'โš ๏ธ Delete Server', + description: `Are you sure you want to **permanently delete** \`${interaction.guild.name}\`?\n\nAll channels, roles, and data will be lost forever. **This cannot be undone.**`, + color: 'error', + }); + + const cancelButton = new ButtonBuilder() + .setCustomId(cancelId) + .setLabel('Cancel') + .setStyle(ButtonStyle.Secondary); + + const confirmButton = new ButtonBuilder() + .setCustomId(confirmId) + .setLabel('Yes, delete the server') + .setStyle(ButtonStyle.Danger); + + const row = new ActionRowBuilder().addComponents(cancelButton, confirmButton); + + await interaction.reply({ + embeds: [embed], + components: [row], + flags: MessageFlags.Ephemeral, + }); + + const collector = interaction.channel.createMessageComponentCollector({ + filter: i => i.user.id === interaction.user.id && [confirmId, cancelId].includes(i.customId), + time: 30_000, + max: 1, + }); + + collector.on('collect', async i => { + if (i.customId === confirmId) { + await i.update({ content: 'Deleting server...', embeds: [], components: [] }).catch(() => {}); + await interaction.guild.delete().catch(async () => { + await interaction.editReply({ + content: '', + embeds: [errorEmbed('Failed', 'Could not delete the server. The bot must be the server owner to perform this action.')], + components: [], + }).catch(() => {}); + }); + } else { + await i.update({ + embeds: [createEmbed({ + title: 'Cancelled', + description: 'Server deletion cancelled.', + color: 'secondary', + })], + components: [], + }); + } + }); + + collector.on('end', collected => { + if (!collected.size) { + interaction.editReply({ + embeds: [createEmbed({ + title: 'Timed Out', + description: 'Server deletion cancelled โ€” no response within 30 seconds.', + color: 'secondary', + })], + components: [], + }).catch(() => {}); + } + }); + }, +}; diff --git a/src/config/changelog.js b/src/config/changelog.js index 835f2672e2..bbf761fa28 100644 --- a/src/config/changelog.js +++ b/src/config/changelog.js @@ -1,4 +1,11 @@ export const changelog = [ + { + version: '2.4.1', + date: '2026-05-02', + entries: [ + { type: 'new', text: 'Added `/deleteserver` โ€” permanently delete the server with a confirmation prompt (server owner only)' }, + ], + }, { version: '2.4.0', date: '2026-05-02', From 022afc1189ed63b6e684edf456fb2fcd5c0e9145 Mon Sep 17 00:00:00 2001 From: Itay100K Date: Sat, 2 May 2026 12:03:29 +0300 Subject: [PATCH 021/275] Add /transferownership command with confirmation prompt Owner-only command to transfer server ownership to another member. Shows a Cancel / Yes confirmation before calling guild.setOwner(). Validates that the target is in the server and is not a bot. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/Moderation/transferownership.js | 128 +++++++++++++++++++ src/config/changelog.js | 7 + 2 files changed, 135 insertions(+) create mode 100644 src/commands/Moderation/transferownership.js diff --git a/src/commands/Moderation/transferownership.js b/src/commands/Moderation/transferownership.js new file mode 100644 index 0000000000..edecc886a1 --- /dev/null +++ b/src/commands/Moderation/transferownership.js @@ -0,0 +1,128 @@ +import { + SlashCommandBuilder, + PermissionFlagsBits, + ButtonBuilder, + ButtonStyle, + ActionRowBuilder, + MessageFlags, +} from 'discord.js'; +import { createEmbed, errorEmbed, successEmbed } from '../../utils/embeds.js'; + +export default { + data: new SlashCommandBuilder() + .setName('transferownership') + .setDescription('Transfer server ownership to another member') + .addUserOption(o => + o.setName('user').setDescription('The member to transfer ownership to').setRequired(true) + ) + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), + category: 'moderation', + + async execute(interaction) { + if (interaction.user.id !== interaction.guild.ownerId) { + return interaction.reply({ + embeds: [errorEmbed('Not Allowed', 'Only the server owner can transfer ownership.')], + flags: MessageFlags.Ephemeral, + }); + } + + const target = interaction.options.getMember('user'); + + if (!target) { + return interaction.reply({ + embeds: [errorEmbed('Not Found', 'That user is not in this server.')], + flags: MessageFlags.Ephemeral, + }); + } + + if (target.id === interaction.user.id) { + return interaction.reply({ + embeds: [errorEmbed('Invalid', 'You are already the server owner.')], + flags: MessageFlags.Ephemeral, + }); + } + + if (target.user.bot) { + return interaction.reply({ + embeds: [errorEmbed('Invalid', 'You cannot transfer ownership to a bot.')], + flags: MessageFlags.Ephemeral, + }); + } + + const confirmId = `transferowner-confirm-${interaction.id}`; + const cancelId = `transferowner-cancel-${interaction.id}`; + + const embed = createEmbed({ + title: '๐Ÿ‘‘ Transfer Ownership', + description: `Are you sure you want to transfer ownership of **${interaction.guild.name}** to ${target}?\n\nYou will lose your owner status and **cannot undo this** without the new owner's consent.`, + color: 'warning', + }); + + const cancelButton = new ButtonBuilder() + .setCustomId(cancelId) + .setLabel('Cancel') + .setStyle(ButtonStyle.Secondary); + + const confirmButton = new ButtonBuilder() + .setCustomId(confirmId) + .setLabel('Yes, transfer ownership') + .setStyle(ButtonStyle.Danger); + + const row = new ActionRowBuilder().addComponents(cancelButton, confirmButton); + + await interaction.reply({ + embeds: [embed], + components: [row], + flags: MessageFlags.Ephemeral, + }); + + const collector = interaction.channel.createMessageComponentCollector({ + filter: i => i.user.id === interaction.user.id && [confirmId, cancelId].includes(i.customId), + time: 30_000, + max: 1, + }); + + collector.on('collect', async i => { + if (i.customId === confirmId) { + await i.deferUpdate().catch(() => {}); + try { + await interaction.guild.setOwner(target); + await interaction.editReply({ + embeds: [successEmbed( + 'Ownership Transferred', + `${target} is now the owner of **${interaction.guild.name}**.`, + )], + components: [], + }); + } catch { + await interaction.editReply({ + embeds: [errorEmbed('Failed', 'Could not transfer ownership. The bot must be the server owner to perform this action.')], + components: [], + }); + } + } else { + await i.update({ + embeds: [createEmbed({ + title: 'Cancelled', + description: 'Ownership transfer cancelled.', + color: 'secondary', + })], + components: [], + }); + } + }); + + collector.on('end', collected => { + if (!collected.size) { + interaction.editReply({ + embeds: [createEmbed({ + title: 'Timed Out', + description: 'Ownership transfer cancelled โ€” no response within 30 seconds.', + color: 'secondary', + })], + components: [], + }).catch(() => {}); + } + }); + }, +}; diff --git a/src/config/changelog.js b/src/config/changelog.js index bbf761fa28..8aa515cbff 100644 --- a/src/config/changelog.js +++ b/src/config/changelog.js @@ -1,4 +1,11 @@ export const changelog = [ + { + version: '2.4.2', + date: '2026-05-02', + entries: [ + { type: 'new', text: 'Added `/transferownership` โ€” transfer server ownership to another member with a confirmation prompt (server owner only)' }, + ], + }, { version: '2.4.1', date: '2026-05-02', From bc9071e79d54df8814fd633dedb910f1eae89f59 Mon Sep 17 00:00:00 2001 From: Itay100K Date: Sat, 2 May 2026 12:04:38 +0300 Subject: [PATCH 022/275] Allow transferring ownership to bots in /transferownership Co-Authored-By: Claude Sonnet 4.6 --- src/commands/Moderation/transferownership.js | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/commands/Moderation/transferownership.js b/src/commands/Moderation/transferownership.js index edecc886a1..751f0de73e 100644 --- a/src/commands/Moderation/transferownership.js +++ b/src/commands/Moderation/transferownership.js @@ -42,13 +42,6 @@ export default { }); } - if (target.user.bot) { - return interaction.reply({ - embeds: [errorEmbed('Invalid', 'You cannot transfer ownership to a bot.')], - flags: MessageFlags.Ephemeral, - }); - } - const confirmId = `transferowner-confirm-${interaction.id}`; const cancelId = `transferowner-cancel-${interaction.id}`; From f694e4cb20e2bb363d6d6d4f9b1b37c2b7f99cbc Mon Sep 17 00:00:00 2001 From: Itay100K Date: Sat, 2 May 2026 12:09:07 +0300 Subject: [PATCH 023/275] Remove /deleteserver and /transferownership commands Discord API does not allow bots to delete guilds or transfer ownership unless the bot itself is the server owner. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/Moderation/deleteserver.js | 94 -------------- src/commands/Moderation/transferownership.js | 121 ------------------- src/config/changelog.js | 9 +- 3 files changed, 1 insertion(+), 223 deletions(-) delete mode 100644 src/commands/Moderation/deleteserver.js delete mode 100644 src/commands/Moderation/transferownership.js diff --git a/src/commands/Moderation/deleteserver.js b/src/commands/Moderation/deleteserver.js deleted file mode 100644 index a6ff6eea60..0000000000 --- a/src/commands/Moderation/deleteserver.js +++ /dev/null @@ -1,94 +0,0 @@ -import { - SlashCommandBuilder, - PermissionFlagsBits, - ButtonBuilder, - ButtonStyle, - ActionRowBuilder, - MessageFlags, -} from 'discord.js'; -import { createEmbed, errorEmbed } from '../../utils/embeds.js'; - -export default { - data: new SlashCommandBuilder() - .setName('deleteserver') - .setDescription('Permanently delete this server โ€” this cannot be undone') - .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), - category: 'moderation', - - async execute(interaction) { - if (interaction.user.id !== interaction.guild.ownerId) { - return interaction.reply({ - embeds: [errorEmbed('Not Allowed', 'Only the server owner can delete the server.')], - flags: MessageFlags.Ephemeral, - }); - } - - const confirmId = `deleteserver-confirm-${interaction.id}`; - const cancelId = `deleteserver-cancel-${interaction.id}`; - - const embed = createEmbed({ - title: 'โš ๏ธ Delete Server', - description: `Are you sure you want to **permanently delete** \`${interaction.guild.name}\`?\n\nAll channels, roles, and data will be lost forever. **This cannot be undone.**`, - color: 'error', - }); - - const cancelButton = new ButtonBuilder() - .setCustomId(cancelId) - .setLabel('Cancel') - .setStyle(ButtonStyle.Secondary); - - const confirmButton = new ButtonBuilder() - .setCustomId(confirmId) - .setLabel('Yes, delete the server') - .setStyle(ButtonStyle.Danger); - - const row = new ActionRowBuilder().addComponents(cancelButton, confirmButton); - - await interaction.reply({ - embeds: [embed], - components: [row], - flags: MessageFlags.Ephemeral, - }); - - const collector = interaction.channel.createMessageComponentCollector({ - filter: i => i.user.id === interaction.user.id && [confirmId, cancelId].includes(i.customId), - time: 30_000, - max: 1, - }); - - collector.on('collect', async i => { - if (i.customId === confirmId) { - await i.update({ content: 'Deleting server...', embeds: [], components: [] }).catch(() => {}); - await interaction.guild.delete().catch(async () => { - await interaction.editReply({ - content: '', - embeds: [errorEmbed('Failed', 'Could not delete the server. The bot must be the server owner to perform this action.')], - components: [], - }).catch(() => {}); - }); - } else { - await i.update({ - embeds: [createEmbed({ - title: 'Cancelled', - description: 'Server deletion cancelled.', - color: 'secondary', - })], - components: [], - }); - } - }); - - collector.on('end', collected => { - if (!collected.size) { - interaction.editReply({ - embeds: [createEmbed({ - title: 'Timed Out', - description: 'Server deletion cancelled โ€” no response within 30 seconds.', - color: 'secondary', - })], - components: [], - }).catch(() => {}); - } - }); - }, -}; diff --git a/src/commands/Moderation/transferownership.js b/src/commands/Moderation/transferownership.js deleted file mode 100644 index 751f0de73e..0000000000 --- a/src/commands/Moderation/transferownership.js +++ /dev/null @@ -1,121 +0,0 @@ -import { - SlashCommandBuilder, - PermissionFlagsBits, - ButtonBuilder, - ButtonStyle, - ActionRowBuilder, - MessageFlags, -} from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed } from '../../utils/embeds.js'; - -export default { - data: new SlashCommandBuilder() - .setName('transferownership') - .setDescription('Transfer server ownership to another member') - .addUserOption(o => - o.setName('user').setDescription('The member to transfer ownership to').setRequired(true) - ) - .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), - category: 'moderation', - - async execute(interaction) { - if (interaction.user.id !== interaction.guild.ownerId) { - return interaction.reply({ - embeds: [errorEmbed('Not Allowed', 'Only the server owner can transfer ownership.')], - flags: MessageFlags.Ephemeral, - }); - } - - const target = interaction.options.getMember('user'); - - if (!target) { - return interaction.reply({ - embeds: [errorEmbed('Not Found', 'That user is not in this server.')], - flags: MessageFlags.Ephemeral, - }); - } - - if (target.id === interaction.user.id) { - return interaction.reply({ - embeds: [errorEmbed('Invalid', 'You are already the server owner.')], - flags: MessageFlags.Ephemeral, - }); - } - - const confirmId = `transferowner-confirm-${interaction.id}`; - const cancelId = `transferowner-cancel-${interaction.id}`; - - const embed = createEmbed({ - title: '๐Ÿ‘‘ Transfer Ownership', - description: `Are you sure you want to transfer ownership of **${interaction.guild.name}** to ${target}?\n\nYou will lose your owner status and **cannot undo this** without the new owner's consent.`, - color: 'warning', - }); - - const cancelButton = new ButtonBuilder() - .setCustomId(cancelId) - .setLabel('Cancel') - .setStyle(ButtonStyle.Secondary); - - const confirmButton = new ButtonBuilder() - .setCustomId(confirmId) - .setLabel('Yes, transfer ownership') - .setStyle(ButtonStyle.Danger); - - const row = new ActionRowBuilder().addComponents(cancelButton, confirmButton); - - await interaction.reply({ - embeds: [embed], - components: [row], - flags: MessageFlags.Ephemeral, - }); - - const collector = interaction.channel.createMessageComponentCollector({ - filter: i => i.user.id === interaction.user.id && [confirmId, cancelId].includes(i.customId), - time: 30_000, - max: 1, - }); - - collector.on('collect', async i => { - if (i.customId === confirmId) { - await i.deferUpdate().catch(() => {}); - try { - await interaction.guild.setOwner(target); - await interaction.editReply({ - embeds: [successEmbed( - 'Ownership Transferred', - `${target} is now the owner of **${interaction.guild.name}**.`, - )], - components: [], - }); - } catch { - await interaction.editReply({ - embeds: [errorEmbed('Failed', 'Could not transfer ownership. The bot must be the server owner to perform this action.')], - components: [], - }); - } - } else { - await i.update({ - embeds: [createEmbed({ - title: 'Cancelled', - description: 'Ownership transfer cancelled.', - color: 'secondary', - })], - components: [], - }); - } - }); - - collector.on('end', collected => { - if (!collected.size) { - interaction.editReply({ - embeds: [createEmbed({ - title: 'Timed Out', - description: 'Ownership transfer cancelled โ€” no response within 30 seconds.', - color: 'secondary', - })], - components: [], - }).catch(() => {}); - } - }); - }, -}; diff --git a/src/config/changelog.js b/src/config/changelog.js index 8aa515cbff..a72307c84f 100644 --- a/src/config/changelog.js +++ b/src/config/changelog.js @@ -3,14 +3,7 @@ export const changelog = [ version: '2.4.2', date: '2026-05-02', entries: [ - { type: 'new', text: 'Added `/transferownership` โ€” transfer server ownership to another member with a confirmation prompt (server owner only)' }, - ], - }, - { - version: '2.4.1', - date: '2026-05-02', - entries: [ - { type: 'new', text: 'Added `/deleteserver` โ€” permanently delete the server with a confirmation prompt (server owner only)' }, + { type: 'removed', text: 'Removed `/deleteserver` and `/transferownership` โ€” Discord does not allow bots to perform these actions unless the bot itself owns the server' }, ], }, { From 2a977f0251dac5bb85a20dec8e04e3bdd2c613c6 Mon Sep 17 00:00:00 2001 From: Itay100K Date: Sat, 2 May 2026 12:36:34 +0300 Subject: [PATCH 024/275] Make personal/informational commands ephemeral Commands now visible only to the user who ran them: - Core: /ping, /stats, /uptime, /bug, /overview, /help - Utility: /userinfo, /serverinfo, /weather, /todo, /firstmsg - Search: /define, /google, /urban, /github - Leveling: /rank, /leveladd - Moderation: /warn, /timeout - Fun: /avatar, /fact, /mock, /reverse Public commands unchanged: /poll, /countdown, /fight, /meme, /joke, /quote, /flip, /ship, /roll, /leaderboard, /ban, /kick Co-Authored-By: Claude Sonnet 4.6 --- .claude/settings.local.json | 6 +++++- src/commands/Core/bug.js | 9 +++++---- src/commands/Core/help.js | 2 +- src/commands/Core/overview.js | 2 +- src/commands/Core/ping.js | 2 +- src/commands/Core/stats.js | 2 +- src/commands/Core/uptime.js | 2 +- src/commands/Fun/avatar.js | 4 ++-- src/commands/Fun/fact.js | 4 ++-- src/commands/Fun/mock.js | 4 ++-- src/commands/Fun/reverse.js | 4 ++-- src/commands/Leveling/leveladd.js | 2 +- src/commands/Leveling/rank.js | 2 +- src/commands/Moderation/timeout.js | 2 +- src/commands/Moderation/warn.js | 2 +- src/commands/Search/define.js | 2 +- src/commands/Search/github.js | 2 +- src/commands/Search/google.js | 2 +- src/commands/Search/urban.js | 7 ++++--- src/commands/Utility/firstmsg.js | 2 +- src/commands/Utility/serverinfo.js | 2 +- src/commands/Utility/todo.js | 2 +- src/commands/Utility/userinfo.js | 2 +- src/commands/Utility/weather.js | 2 +- 24 files changed, 39 insertions(+), 33 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 91712ff29a..7476045e17 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -14,7 +14,11 @@ "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\\\\\", \"\"\\) })" + "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\")" ] } } diff --git a/src/commands/Core/bug.js b/src/commands/Core/bug.js index 9703be0ee6..45f9d93000 100644 --- a/src/commands/Core/bug.js +++ b/src/commands/Core/bug.js @@ -19,10 +19,10 @@ export default { 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' + + '๏ฟฝ ?? 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' }) @@ -31,6 +31,7 @@ export default { await InteractionHelper.safeReply(interaction, { embeds: [bugReportEmbed], components: [row], + flags: [MessageFlags.Ephemeral], }); }, }; diff --git a/src/commands/Core/help.js b/src/commands/Core/help.js index 041c3d60a4..3fc2fad9ab 100644 --- a/src/commands/Core/help.js +++ b/src/commands/Core/help.js @@ -126,7 +126,7 @@ export default { .setDescription("Displays commands available to you based on your permissions"), async execute(interaction, guildConfig, client) { - await InteractionHelper.safeDefer(interaction); + await InteractionHelper.safeDefer(interaction, { ephemeral: true }); const { embeds, components } = await createInitialHelpMenu(client, interaction.member); diff --git a/src/commands/Core/overview.js b/src/commands/Core/overview.js index 4c06839a80..c5647360e5 100644 --- a/src/commands/Core/overview.js +++ b/src/commands/Core/overview.js @@ -34,7 +34,7 @@ export default { async execute(interaction, config, client) { try { - await InteractionHelper.safeDefer(interaction); + await InteractionHelper.safeDefer(interaction, { ephemeral: true }); const [guildConfig, loggingStatus, levelingConfig, welcomeConfig, applicationConfig, joinToCreateConfig] = await Promise.all([ diff --git a/src/commands/Core/ping.js b/src/commands/Core/ping.js index c20d1ad496..816002b1ee 100644 --- a/src/commands/Core/ping.js +++ b/src/commands/Core/ping.js @@ -9,7 +9,7 @@ export default { .setDescription("Checks the bot's latency and API speed"), async execute(interaction) { - const deferSuccess = await InteractionHelper.safeDefer(interaction); + const deferSuccess = await InteractionHelper.safeDefer(interaction, { ephemeral: true }); if (!deferSuccess) { logger.warn(`Ping interaction defer failed`, { userId: interaction.user.id, diff --git a/src/commands/Core/stats.js b/src/commands/Core/stats.js index b2e634a6af..8ac29180f0 100644 --- a/src/commands/Core/stats.js +++ b/src/commands/Core/stats.js @@ -10,7 +10,7 @@ export default { async execute(interaction) { try { - await InteractionHelper.safeDefer(interaction); + await InteractionHelper.safeDefer(interaction, { ephemeral: true }); const totalGuilds = interaction.client.guilds.cache.size; const totalMembers = interaction.client.guilds.cache.reduce( diff --git a/src/commands/Core/uptime.js b/src/commands/Core/uptime.js index 7c33d49b82..7b96d74cab 100644 --- a/src/commands/Core/uptime.js +++ b/src/commands/Core/uptime.js @@ -10,7 +10,7 @@ export default { async execute(interaction) { try { - await InteractionHelper.safeDefer(interaction); + await InteractionHelper.safeDefer(interaction, { ephemeral: true }); let totalSeconds = interaction.client.uptime / 1000; let days = Math.floor(totalSeconds / 86400); diff --git a/src/commands/Fun/avatar.js b/src/commands/Fun/avatar.js index 66ed80995a..baf652b493 100644 --- a/src/commands/Fun/avatar.js +++ b/src/commands/Fun/avatar.js @@ -1,4 +1,4 @@ -import { SlashCommandBuilder } from 'discord.js'; +import { SlashCommandBuilder, MessageFlags } from 'discord.js'; import { createEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; import { handleInteractionError } from '../../utils/errorHandler.js'; @@ -33,7 +33,7 @@ export default { color: 'blurple', }); - await InteractionHelper.safeReply(interaction, { embeds: [embed] }); + await InteractionHelper.safeReply(interaction, { embeds: [embed], flags: [MessageFlags.Ephemeral] }); logger.debug(`Avatar command used for ${targetUser.id} by ${interaction.user.id}`); } catch (error) { logger.error('Avatar command error:', error); diff --git a/src/commands/Fun/fact.js b/src/commands/Fun/fact.js index 1925b2331b..8b31f89eaa 100644 --- a/src/commands/Fun/fact.js +++ b/src/commands/Fun/fact.js @@ -1,4 +1,4 @@ -import { SlashCommandBuilder } from 'discord.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, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; @@ -25,7 +25,7 @@ export default { const embed = successEmbed("๐Ÿง  Did You Know?", `๐Ÿ’ก **${randomFact}**`); - await InteractionHelper.safeReply(interaction, { embeds: [embed] }); + await InteractionHelper.safeReply(interaction, { embeds: [embed], flags: [MessageFlags.Ephemeral] }); logger.debug(`Fact command executed by user ${interaction.user.id} in guild ${interaction.guildId}`); } catch (error) { logger.error('Fact command error:', error); diff --git a/src/commands/Fun/mock.js b/src/commands/Fun/mock.js index 4caf91f94a..171fcd7e7a 100644 --- a/src/commands/Fun/mock.js +++ b/src/commands/Fun/mock.js @@ -1,4 +1,4 @@ -import { SlashCommandBuilder } from 'discord.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, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; @@ -46,7 +46,7 @@ export default { const embed = successEmbed("sPoNgEbOb cAsE", `"${mockedText}"`); - await InteractionHelper.safeReply(interaction, { embeds: [embed] }); + await InteractionHelper.safeReply(interaction, { embeds: [embed], flags: [MessageFlags.Ephemeral] }); logger.debug(`Mock command executed by user ${interaction.user.id} in guild ${interaction.guildId}`); } catch (error) { logger.error('Mock command error:', error); diff --git a/src/commands/Fun/reverse.js b/src/commands/Fun/reverse.js index 5f6357c8a9..a7080330a6 100644 --- a/src/commands/Fun/reverse.js +++ b/src/commands/Fun/reverse.js @@ -1,4 +1,4 @@ -import { SlashCommandBuilder } from 'discord.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, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; @@ -40,7 +40,7 @@ export default { `Original: **${sanitizedText}**\nReversed: **${reversedText}**`, ); - await InteractionHelper.safeReply(interaction, { embeds: [embed] }); + await InteractionHelper.safeReply(interaction, { embeds: [embed], flags: [MessageFlags.Ephemeral] }); logger.debug(`Reverse command executed by user ${interaction.user.id} in guild ${interaction.guildId}`); } catch (error) { logger.error('Reverse command error:', error); diff --git a/src/commands/Leveling/leveladd.js b/src/commands/Leveling/leveladd.js index e513b421ee..d594dbce82 100644 --- a/src/commands/Leveling/leveladd.js +++ b/src/commands/Leveling/leveladd.js @@ -40,7 +40,7 @@ export default { async execute(interaction, config, client) { try { - await InteractionHelper.safeDefer(interaction); + await InteractionHelper.safeDefer(interaction, { ephemeral: true }); const hasPermission = await checkUserPermissions( diff --git a/src/commands/Leveling/rank.js b/src/commands/Leveling/rank.js index 8a43e44683..72857924be 100644 --- a/src/commands/Leveling/rank.js +++ b/src/commands/Leveling/rank.js @@ -30,7 +30,7 @@ export default { async execute(interaction, config, client) { try { - await InteractionHelper.safeDefer(interaction); + await InteractionHelper.safeDefer(interaction, { ephemeral: true }); const levelingConfig = await getLevelingConfig(client, interaction.guildId); if (!levelingConfig?.enabled) { diff --git a/src/commands/Moderation/timeout.js b/src/commands/Moderation/timeout.js index 04f205a3f4..7ac30921a3 100644 --- a/src/commands/Moderation/timeout.js +++ b/src/commands/Moderation/timeout.js @@ -40,7 +40,7 @@ export default { category: "moderation", async execute(interaction, config, client) { - const deferSuccess = await InteractionHelper.safeDefer(interaction); + const deferSuccess = await InteractionHelper.safeDefer(interaction, { ephemeral: true }); if (!deferSuccess) { logger.warn(`Timeout interaction defer failed`, { userId: interaction.user.id, diff --git a/src/commands/Moderation/warn.js b/src/commands/Moderation/warn.js index 571214ece0..683b231ea6 100644 --- a/src/commands/Moderation/warn.js +++ b/src/commands/Moderation/warn.js @@ -25,7 +25,7 @@ export default { category: "moderation", async execute(interaction, config, client) { - const deferSuccess = await InteractionHelper.safeDefer(interaction); + const deferSuccess = await InteractionHelper.safeDefer(interaction, { ephemeral: true }); if (!deferSuccess) { logger.warn(`Warn interaction defer failed`, { userId: interaction.user.id, diff --git a/src/commands/Search/define.js b/src/commands/Search/define.js index 97dd195da6..41bc8ec45c 100644 --- a/src/commands/Search/define.js +++ b/src/commands/Search/define.js @@ -17,7 +17,7 @@ export default { async execute(interaction) { try { - const deferred = await InteractionHelper.safeDefer(interaction); + const deferred = await InteractionHelper.safeDefer(interaction, { ephemeral: true }); if (!deferred) { return; } diff --git a/src/commands/Search/github.js b/src/commands/Search/github.js index d601cfe72e..50a6b8ebc1 100644 --- a/src/commands/Search/github.js +++ b/src/commands/Search/github.js @@ -14,7 +14,7 @@ export default { async execute(interaction) { try { - await InteractionHelper.safeDefer(interaction); + await InteractionHelper.safeDefer(interaction, { ephemeral: true }); const query = interaction.options.getString('query').trim(); const isRepo = query.includes('/'); diff --git a/src/commands/Search/google.js b/src/commands/Search/google.js index 81844faca4..fefaee5602 100644 --- a/src/commands/Search/google.js +++ b/src/commands/Search/google.js @@ -25,7 +25,7 @@ export default { }) .setFooter({ text: 'Google Search Results' }); - await InteractionHelper.safeReply(interaction, { embeds: [embed] }); + await InteractionHelper.safeReply(interaction, { embeds: [embed], flags: [MessageFlags.Ephemeral] }); logger.info('Google search link generated', { userId: interaction.user.id, diff --git a/src/commands/Search/urban.js b/src/commands/Search/urban.js index e2020025b8..09f444e800 100644 --- a/src/commands/Search/urban.js +++ b/src/commands/Search/urban.js @@ -54,7 +54,7 @@ export default { }; deferTimer = setTimeout(() => { - InteractionHelper.safeDefer(interaction).catch((deferError) => { + InteractionHelper.safeDefer(interaction, { ephemeral: true }).catch((deferError) => { logger.debug('Urban command defer fallback failed', { error: deferError?.message, interactionId: interaction.id, @@ -71,7 +71,8 @@ export default { if (!response.data?.list?.length) { return await InteractionHelper.safeReply(interaction, { - embeds: [errorEmbed('Not Found', `No definitions found for "${term}" on Urban Dictionary.`)] + embeds: [errorEmbed('Not Found', `No definitions found for "${term}" on Urban Dictionary.`)], + flags: [MessageFlags.Ephemeral], }); } @@ -115,7 +116,7 @@ export default { iconURL: 'https://i.imgur.com/8aQrX3a.png' }); - await InteractionHelper.safeReply(interaction, { embeds: [embed] }); + await InteractionHelper.safeReply(interaction, { embeds: [embed], flags: [MessageFlags.Ephemeral] }); logger.info('Urban Dictionary definition retrieved', { userId: interaction.user.id, diff --git a/src/commands/Utility/firstmsg.js b/src/commands/Utility/firstmsg.js index 6603208efd..09511cc6e0 100644 --- a/src/commands/Utility/firstmsg.js +++ b/src/commands/Utility/firstmsg.js @@ -13,7 +13,7 @@ export default { async execute(interaction, config, client) { try { - const deferSuccess = await InteractionHelper.safeDefer(interaction); + const deferSuccess = await InteractionHelper.safeDefer(interaction, { ephemeral: true }); if (!deferSuccess) { logger.warn(`FirstMsg interaction defer failed`, { userId: interaction.user.id, diff --git a/src/commands/Utility/serverinfo.js b/src/commands/Utility/serverinfo.js index b471420dc8..9e93231f45 100644 --- a/src/commands/Utility/serverinfo.js +++ b/src/commands/Utility/serverinfo.js @@ -11,7 +11,7 @@ export default { async execute(interaction) { try { - const deferSuccess = await InteractionHelper.safeDefer(interaction); + const deferSuccess = await InteractionHelper.safeDefer(interaction, { ephemeral: true }); if (!deferSuccess) { logger.warn(`ServerInfo interaction defer failed`, { userId: interaction.user.id, diff --git a/src/commands/Utility/todo.js b/src/commands/Utility/todo.js index 8ee6963992..7c32d9f9fe 100644 --- a/src/commands/Utility/todo.js +++ b/src/commands/Utility/todo.js @@ -170,7 +170,7 @@ export default { } try { - const deferSuccess = await InteractionHelper.safeDefer(interaction); + const deferSuccess = await InteractionHelper.safeDefer(interaction, { ephemeral: true }); if (!deferSuccess) { logger.warn(`Todo interaction defer failed`, { userId: interaction.user.id, diff --git a/src/commands/Utility/userinfo.js b/src/commands/Utility/userinfo.js index 40dd58cd58..a714e7cf2c 100644 --- a/src/commands/Utility/userinfo.js +++ b/src/commands/Utility/userinfo.js @@ -15,7 +15,7 @@ export default { async execute(interaction) { try { - const deferSuccess = await InteractionHelper.safeDefer(interaction); + const deferSuccess = await InteractionHelper.safeDefer(interaction, { ephemeral: true }); if (!deferSuccess) { logger.warn(`UserInfo interaction defer failed`, { userId: interaction.user.id, diff --git a/src/commands/Utility/weather.js b/src/commands/Utility/weather.js index 6a47cde5db..e5e26e8d01 100644 --- a/src/commands/Utility/weather.js +++ b/src/commands/Utility/weather.js @@ -19,7 +19,7 @@ export default { async execute(interaction) { try { - const deferSuccess = await InteractionHelper.safeDefer(interaction); + const deferSuccess = await InteractionHelper.safeDefer(interaction, { ephemeral: true }); if (!deferSuccess) { logger.warn(`Weather interaction defer failed`, { userId: interaction.user.id, From 672f3f2fceaa2bd6ed53f9526cd272591c1251ff Mon Sep 17 00:00:00 2001 From: Itay100K Date: Sat, 2 May 2026 12:42:08 +0300 Subject: [PATCH 025/275] Add public/private visibility choice to /changelog Users can now choose whether the changelog posts publicly to the channel or privately (ephemeral, visible only to them). Co-Authored-By: Claude Sonnet 4.6 --- src/commands/Core/changelog.js | 36 +++++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/src/commands/Core/changelog.js b/src/commands/Core/changelog.js index 714e67da1b..67317909da 100644 --- a/src/commands/Core/changelog.js +++ b/src/commands/Core/changelog.js @@ -8,7 +8,17 @@ import { changelog, typeEmoji } from '../../config/changelog.js'; export default { data: new SlashCommandBuilder() .setName('changelog') - .setDescription('Post the bot changelog to this channel') + .setDescription('Post the bot changelog') + .addStringOption(option => + option + .setName('visibility') + .setDescription('Who can see the changelog') + .setRequired(true) + .addChoices( + { name: 'Public โ€” post to this channel', value: 'public' }, + { name: 'Private โ€” only visible to you', value: 'private' }, + ) + ) .addBooleanOption(option => option .setName('all') @@ -19,32 +29,40 @@ export default { async execute(interaction) { try { + const visibility = interaction.options.getString('visibility'); + const isPublic = visibility === 'public'; const showAll = interaction.options.getBoolean('all') ?? false; const versions = showAll ? changelog : [changelog[0]]; await InteractionHelper.safeDefer(interaction, { ephemeral: true }); - for (const version of versions) { + const embeds = versions.map(version => { const lines = version.entries.map(e => `${typeEmoji[e.type] ?? 'โ€ข'} ${e.text}` ).join('\n'); - const embed = createEmbed({ + return createEmbed({ title: `๐Ÿ“‹ Changelog โ€” v${version.version}`, description: lines, color: 'blurple', footer: { text: `Released ${version.date}` }, timestamp: false, }); + }); - await interaction.channel.send({ embeds: [embed] }); + if (isPublic) { + for (const embed of embeds) { + await interaction.channel.send({ embeds: [embed] }); + } + await InteractionHelper.safeEditReply(interaction, { + content: `Posted ${versions.length === 1 ? 'latest version' : 'all versions'} publicly.`, + }); + } else { + await InteractionHelper.safeEditReply(interaction, { + embeds, + }); } - await InteractionHelper.safeEditReply(interaction, { - content: `Posted ${versions.length === 1 ? 'latest version' : 'all versions'}.`, - flags: [MessageFlags.Ephemeral], - }); - logger.info(`Changelog posted by ${interaction.user.id} in ${interaction.channel.id}`); } catch (error) { logger.error('Changelog command error:', error); From 9039ebc1d52d1c493d47d6285d74e8bb37341c57 Mon Sep 17 00:00:00 2001 From: Itay100K Date: Sat, 2 May 2026 16:49:42 +0300 Subject: [PATCH 026/275] Add Discord invite link to bot activity status Co-Authored-By: Claude Sonnet 4.6 --- .claude/settings.local.json | 3 ++- src/config/botConfig.js | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 7476045e17..659aac289e 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -18,7 +18,8 @@ "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(Get-ChildItem -Path \"c:\\\\code\\\\zen-bot\\\\src\\\\commands\" -Recurse -Filter \"*.js\")", + "Bash(git -C \"/c/code/zen-bot\" log --oneline -5)" ] } } diff --git a/src/config/botConfig.js b/src/config/botConfig.js index 9ee33afd95..9dabe505ed 100644 --- a/src/config/botConfig.js +++ b/src/config/botConfig.js @@ -28,7 +28,8 @@ export const botConfig = { { name: "/help", type: 3 }, { name: "zen vibes", type: 2 }, { name: "itay100k build this bot", type: 5 }, - + { name: "https://discord.gg/3znmrNRS7x", type: 0 }, + ], }, From 71f33072459c695e838308ff1bd4ba8b170c843a Mon Sep 17 00:00:00 2001 From: Itay100K Date: Sun, 3 May 2026 07:41:40 +0300 Subject: [PATCH 027/275] Make mod and admin commands private (ephemeral) All moderation commands now reply privately so only the moderator sees the response. Admin/config commands (logging, goodbye, counter, reactroles setup, verification remove) are also now ephemeral. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/Logging/logging.js | 2 +- .../Logging/modules/logging_dashboard.js | 3 ++- src/commands/Moderation/autoresponder.js | 2 +- src/commands/Moderation/ban.js | 1 + src/commands/Moderation/cases.js | 2 +- src/commands/Moderation/dm.js | 2 +- src/commands/Moderation/kick.js | 3 ++- src/commands/Moderation/lock.js | 2 +- src/commands/Moderation/massban.js | 2 +- src/commands/Moderation/masskick.js | 2 +- src/commands/Moderation/slowmode.js | 1 + src/commands/Moderation/temprole.js | 2 +- src/commands/Moderation/unban.js | 2 +- src/commands/Moderation/unlock.js | 2 +- src/commands/Moderation/untimeout.js | 2 +- src/commands/Moderation/usernotes.js | 20 +++++++++++++++---- src/commands/Moderation/warnings.js | 2 +- src/commands/Reaction_roles/reactroles.js | 4 ++-- .../ServerStats/modules/serverstats_create.js | 2 +- .../ServerStats/modules/serverstats_delete.js | 2 +- .../ServerStats/modules/serverstats_list.js | 2 +- .../ServerStats/modules/serverstats_update.js | 2 +- src/commands/Verification/verification.js | 3 ++- src/commands/Welcome/goodbye.js | 2 +- src/config/botConfig.js | 1 - src/config/changelog.js | 8 ++++++++ 26 files changed, 51 insertions(+), 27 deletions(-) diff --git a/src/commands/Logging/logging.js b/src/commands/Logging/logging.js index 6009f6b8e7..a7c53cc52c 100644 --- a/src/commands/Logging/logging.js +++ b/src/commands/Logging/logging.js @@ -94,7 +94,7 @@ export default { return await dashboard.execute(interaction, config, client); } - await InteractionHelper.safeDefer(interaction); + await InteractionHelper.safeDefer(interaction, { ephemeral: true }); if (subcommand === 'setchannel') { return await setchannel.execute(interaction, config, client); diff --git a/src/commands/Logging/modules/logging_dashboard.js b/src/commands/Logging/modules/logging_dashboard.js index a2d8e18dc1..7ea841bc30 100644 --- a/src/commands/Logging/modules/logging_dashboard.js +++ b/src/commands/Logging/modules/logging_dashboard.js @@ -119,10 +119,11 @@ export default { 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.')], + ephemeral: true, }); } - await InteractionHelper.safeDefer(interaction); + await InteractionHelper.safeDefer(interaction, { ephemeral: true }); const { embed, components } = await buildLoggingDashboardView(interaction, client); await InteractionHelper.safeEditReply(interaction, { embeds: [embed], components }); } catch (error) { diff --git a/src/commands/Moderation/autoresponder.js b/src/commands/Moderation/autoresponder.js index 440aec2b05..c9070228a8 100644 --- a/src/commands/Moderation/autoresponder.js +++ b/src/commands/Moderation/autoresponder.js @@ -47,7 +47,7 @@ export default { category: 'moderation', async execute(interaction, config, client) { - await InteractionHelper.safeDefer(interaction); + await InteractionHelper.safeDefer(interaction, { ephemeral: true }); const sub = interaction.options.getSubcommand(); const guildId = interaction.guildId; diff --git a/src/commands/Moderation/ban.js b/src/commands/Moderation/ban.js index 6573592334..89f70f0a2a 100644 --- a/src/commands/Moderation/ban.js +++ b/src/commands/Moderation/ban.js @@ -48,6 +48,7 @@ export default { `**Reason:** ${reason}\n**Case ID:** #${result.caseId}`, ), ], + ephemeral: true, }); } catch (error) { logger.error('Ban command error:', error); diff --git a/src/commands/Moderation/cases.js b/src/commands/Moderation/cases.js index ad16592497..102e828ccf 100644 --- a/src/commands/Moderation/cases.js +++ b/src/commands/Moderation/cases.js @@ -32,7 +32,7 @@ export default { ), async execute(interaction, config, client) { - const deferSuccess = await InteractionHelper.safeDefer(interaction); + const deferSuccess = await InteractionHelper.safeDefer(interaction, { ephemeral: true }); if (!deferSuccess) { logger.warn(`Cases interaction defer failed`, { userId: interaction.user.id, diff --git a/src/commands/Moderation/dm.js b/src/commands/Moderation/dm.js index b092f672de..e80678709c 100644 --- a/src/commands/Moderation/dm.js +++ b/src/commands/Moderation/dm.js @@ -32,7 +32,7 @@ export default { category: "Moderation", async execute(interaction, config, client) { - const deferSuccess = await InteractionHelper.safeDefer(interaction); + const deferSuccess = await InteractionHelper.safeDefer(interaction, { ephemeral: true }); if (!deferSuccess) { logger.warn(`DM interaction defer failed`, { userId: interaction.user.id, diff --git a/src/commands/Moderation/kick.js b/src/commands/Moderation/kick.js index d1902f9068..fac70ae459 100644 --- a/src/commands/Moderation/kick.js +++ b/src/commands/Moderation/kick.js @@ -109,6 +109,7 @@ export default { `**Reason:** ${reason}\n**Case ID:** #${caseId}`, ), ], + ephemeral: true, }); } catch (error) { logger.error('Kick command error:', error); @@ -116,7 +117,7 @@ export default { "An unexpected error occurred while trying to kick the user.", error.message || "Could not kick the user" ); - await InteractionHelper.universalReply(interaction, { embeds: [errorEmbed_default] }); + await InteractionHelper.universalReply(interaction, { embeds: [errorEmbed_default], ephemeral: true }); } } }; diff --git a/src/commands/Moderation/lock.js b/src/commands/Moderation/lock.js index 7705db393f..49b102f7fc 100644 --- a/src/commands/Moderation/lock.js +++ b/src/commands/Moderation/lock.js @@ -15,7 +15,7 @@ export default { category: "moderation", async execute(interaction, config, client) { - const deferSuccess = await InteractionHelper.safeDefer(interaction); + const deferSuccess = await InteractionHelper.safeDefer(interaction, { ephemeral: true }); if (!deferSuccess) { logger.warn(`Lock interaction defer failed`, { userId: interaction.user.id, diff --git a/src/commands/Moderation/massban.js b/src/commands/Moderation/massban.js index b3e6f6ef1d..bd760736b5 100644 --- a/src/commands/Moderation/massban.js +++ b/src/commands/Moderation/massban.js @@ -32,7 +32,7 @@ export default { category: "moderation", async execute(interaction, config, client) { - const deferSuccess = await InteractionHelper.safeDefer(interaction); + const deferSuccess = await InteractionHelper.safeDefer(interaction, { ephemeral: true }); if (!deferSuccess) { logger.warn(`Massban interaction defer failed`, { userId: interaction.user.id, diff --git a/src/commands/Moderation/masskick.js b/src/commands/Moderation/masskick.js index b6471bdcaf..72fcab56e7 100644 --- a/src/commands/Moderation/masskick.js +++ b/src/commands/Moderation/masskick.js @@ -24,7 +24,7 @@ export default { category: "moderation", async execute(interaction, config, client) { - const deferSuccess = await InteractionHelper.safeDefer(interaction); + const deferSuccess = await InteractionHelper.safeDefer(interaction, { ephemeral: true }); if (!deferSuccess) { logger.warn(`Masskick interaction defer failed`, { userId: interaction.user.id, diff --git a/src/commands/Moderation/slowmode.js b/src/commands/Moderation/slowmode.js index f0f2a6202a..d4502e88f4 100644 --- a/src/commands/Moderation/slowmode.js +++ b/src/commands/Moderation/slowmode.js @@ -44,6 +44,7 @@ export default { await InteractionHelper.safeReply(interaction, { embeds: [successEmbed('Slowmode Updated', description)], + ephemeral: true, }); logger.info(`Slowmode set to ${seconds}s in channel ${channel.id} by ${interaction.user.id}`); diff --git a/src/commands/Moderation/temprole.js b/src/commands/Moderation/temprole.js index e6ec7d8396..017b55b487 100644 --- a/src/commands/Moderation/temprole.js +++ b/src/commands/Moderation/temprole.js @@ -39,7 +39,7 @@ export default { category: 'moderation', async execute(interaction, config, client) { - await InteractionHelper.safeDefer(interaction); + await InteractionHelper.safeDefer(interaction, { ephemeral: true }); const sub = interaction.options.getSubcommand(); const guildId = interaction.guildId; diff --git a/src/commands/Moderation/unban.js b/src/commands/Moderation/unban.js index d321969451..5e95126b8e 100644 --- a/src/commands/Moderation/unban.js +++ b/src/commands/Moderation/unban.js @@ -24,7 +24,7 @@ export default { category: "moderation", async execute(interaction, config, client) { - const deferSuccess = await InteractionHelper.safeDefer(interaction); + const deferSuccess = await InteractionHelper.safeDefer(interaction, { ephemeral: true }); if (!deferSuccess) { logger.warn(`Unban interaction defer failed`, { userId: interaction.user.id, diff --git a/src/commands/Moderation/unlock.js b/src/commands/Moderation/unlock.js index 876dc3e563..4500956103 100644 --- a/src/commands/Moderation/unlock.js +++ b/src/commands/Moderation/unlock.js @@ -15,7 +15,7 @@ export default { category: "moderation", async execute(interaction, config, client) { - const deferSuccess = await InteractionHelper.safeDefer(interaction); + const deferSuccess = await InteractionHelper.safeDefer(interaction, { ephemeral: true }); if (!deferSuccess) { logger.warn(`Unlock interaction defer failed`, { userId: interaction.user.id, diff --git a/src/commands/Moderation/untimeout.js b/src/commands/Moderation/untimeout.js index a55882953e..61b31b540f 100644 --- a/src/commands/Moderation/untimeout.js +++ b/src/commands/Moderation/untimeout.js @@ -19,7 +19,7 @@ export default { category: "moderation", async execute(interaction, config, client) { - const deferSuccess = await InteractionHelper.safeDefer(interaction); + const deferSuccess = await InteractionHelper.safeDefer(interaction, { ephemeral: true }); if (!deferSuccess) { logger.warn(`Untimeout interaction defer failed`, { userId: interaction.user.id, diff --git a/src/commands/Moderation/usernotes.js b/src/commands/Moderation/usernotes.js index c1db5f7cca..d4732dd50f 100644 --- a/src/commands/Moderation/usernotes.js +++ b/src/commands/Moderation/usernotes.js @@ -108,6 +108,7 @@ export default { "You do not have permission to manage user notes." ), ], + flags: MessageFlags.Ephemeral, }); } @@ -123,6 +124,7 @@ export default { "Please select a valid subcommand." ), ], + flags: MessageFlags.Ephemeral, }); } @@ -150,6 +152,7 @@ export default { "Please select a valid subcommand." ), ], + flags: MessageFlags.Ephemeral, }); } } catch (error) { @@ -179,6 +182,7 @@ async function handleAddNote(interaction, targetUser, notes, guildId) { "Notes must be 1000 characters or less." ), ], + flags: MessageFlags.Ephemeral, }); } @@ -190,6 +194,7 @@ async function handleAddNote(interaction, targetUser, notes, guildId) { "Note cannot be empty." ), ], + flags: MessageFlags.Ephemeral, }); } @@ -221,7 +226,8 @@ async function handleAddNote(interaction, targetUser, notes, guildId) { `**Moderator:** ${interaction.user.tag}\n` + `**Total Notes:** ${notes.length}` ) - ] + ], + flags: MessageFlags.Ephemeral, }); } @@ -234,6 +240,7 @@ async function handleViewNotes(interaction, targetUser, notes) { `There are no notes for **${targetUser.tag}**.` ), ], + flags: MessageFlags.Ephemeral, }); } @@ -259,7 +266,8 @@ async function handleViewNotes(interaction, targetUser, notes) { `๐Ÿ“ User Notes (${notes.length})`, description ) - ] + ], + flags: MessageFlags.Ephemeral, }); } @@ -274,6 +282,7 @@ const index = interaction.options.getInteger("index") - 1; `Please provide a valid note index (1-${notes.length}).` ), ], + flags: MessageFlags.Ephemeral, }); } @@ -293,7 +302,8 @@ const index = interaction.options.getInteger("index") - 1; `> ${removedNote.content}\n\n` + `**Remaining Notes:** ${notes.length}` ) - ] + ], + flags: MessageFlags.Ephemeral, }); } @@ -308,6 +318,7 @@ async function handleClearNotes(interaction, targetUser, notes, guildId) { `There are no notes for **${targetUser.tag}** to clear.` ), ], + flags: MessageFlags.Ephemeral, }); } @@ -322,7 +333,8 @@ async function handleClearNotes(interaction, targetUser, notes, guildId) { "๐Ÿ—‘๏ธ Notes Cleared", `Cleared **${noteCount}** notes from **${targetUser.tag}**.` ) - ] + ], + flags: MessageFlags.Ephemeral, }); } diff --git a/src/commands/Moderation/warnings.js b/src/commands/Moderation/warnings.js index 834bdd1da4..31b93918d6 100644 --- a/src/commands/Moderation/warnings.js +++ b/src/commands/Moderation/warnings.js @@ -20,7 +20,7 @@ export default { category: "moderation", async execute(interaction, config, client) { - const deferSuccess = await InteractionHelper.safeDefer(interaction); + const deferSuccess = await InteractionHelper.safeDefer(interaction, { ephemeral: true }); if (!deferSuccess) { logger.warn(`Warnings interaction defer failed`, { userId: interaction.user.id, diff --git a/src/commands/Reaction_roles/reactroles.js b/src/commands/Reaction_roles/reactroles.js index bc762be8a5..d83d5d873d 100644 --- a/src/commands/Reaction_roles/reactroles.js +++ b/src/commands/Reaction_roles/reactroles.js @@ -173,9 +173,9 @@ export default { // โ”€โ”€โ”€ Setup Subcommand โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ async function handleSetup(interaction) { - const deferSuccess = await InteractionHelper.safeDefer(interaction); + const deferSuccess = await InteractionHelper.safeDefer(interaction, { ephemeral: true }); if (!deferSuccess) return; - + logger.info(`Reaction role setup initiated by ${interaction.user.tag} in guild ${interaction.guild.name}`); const channel = interaction.options.getChannel('channel'); diff --git a/src/commands/ServerStats/modules/serverstats_create.js b/src/commands/ServerStats/modules/serverstats_create.js index 9bd4912507..a082436fc4 100644 --- a/src/commands/ServerStats/modules/serverstats_create.js +++ b/src/commands/ServerStats/modules/serverstats_create.js @@ -17,7 +17,7 @@ export async function handleCreate(interaction, client) { // Defer reply immediately to ensure interaction is acknowledged try { - await InteractionHelper.safeDefer(interaction); + await InteractionHelper.safeDefer(interaction, { ephemeral: true }); } catch (error) { logger.error("Failed to defer reply:", error); return; diff --git a/src/commands/ServerStats/modules/serverstats_delete.js b/src/commands/ServerStats/modules/serverstats_delete.js index e0f52f204e..61ec58e9c2 100644 --- a/src/commands/ServerStats/modules/serverstats_delete.js +++ b/src/commands/ServerStats/modules/serverstats_delete.js @@ -16,7 +16,7 @@ export async function handleDelete(interaction, client) { // Defer reply immediately to ensure interaction is acknowledged try { - await InteractionHelper.safeDefer(interaction); + await InteractionHelper.safeDefer(interaction, { ephemeral: true }); } catch (error) { logger.error("Failed to defer reply:", error); return; diff --git a/src/commands/ServerStats/modules/serverstats_list.js b/src/commands/ServerStats/modules/serverstats_list.js index 24c3aefec3..63d7583727 100644 --- a/src/commands/ServerStats/modules/serverstats_list.js +++ b/src/commands/ServerStats/modules/serverstats_list.js @@ -15,7 +15,7 @@ export async function handleList(interaction, client) { // Defer reply immediately to ensure interaction is acknowledged try { - await InteractionHelper.safeDefer(interaction); + await InteractionHelper.safeDefer(interaction, { ephemeral: true }); } catch (error) { logger.error("Failed to defer reply:", error); return; diff --git a/src/commands/ServerStats/modules/serverstats_update.js b/src/commands/ServerStats/modules/serverstats_update.js index cd3d30ece3..70214a66b5 100644 --- a/src/commands/ServerStats/modules/serverstats_update.js +++ b/src/commands/ServerStats/modules/serverstats_update.js @@ -16,7 +16,7 @@ export async function handleUpdate(interaction, client) { // Defer reply immediately to ensure interaction is acknowledged try { - await InteractionHelper.safeDefer(interaction); + await InteractionHelper.safeDefer(interaction, { ephemeral: true }); } catch (error) { logger.error("Failed to defer reply:", error); return; diff --git a/src/commands/Verification/verification.js b/src/commands/Verification/verification.js index e43ce5e05c..9a107ee904 100644 --- a/src/commands/Verification/verification.js +++ b/src/commands/Verification/verification.js @@ -249,7 +249,8 @@ async function handleRemove(interaction, guild, client) { }); return await InteractionHelper.safeReply(interaction, { - embeds: [successEmbed("Verification Removed", `Verification removed from ${targetUser.tag}.`)] + embeds: [successEmbed("Verification Removed", `Verification removed from ${targetUser.tag}.`)], + flags: MessageFlags.Ephemeral }); } catch (error) { diff --git a/src/commands/Welcome/goodbye.js b/src/commands/Welcome/goodbye.js index 736924a5a2..8ec5238f04 100644 --- a/src/commands/Welcome/goodbye.js +++ b/src/commands/Welcome/goodbye.js @@ -34,7 +34,7 @@ export default { .setRequired(false))), async execute(interaction) { - const deferSuccess = await InteractionHelper.safeDefer(interaction); + const deferSuccess = await InteractionHelper.safeDefer(interaction, { ephemeral: true }); if (!deferSuccess) { logger.warn(`Goodbye interaction defer failed`, { userId: interaction.user.id, diff --git a/src/config/botConfig.js b/src/config/botConfig.js index 9dabe505ed..a9adddf596 100644 --- a/src/config/botConfig.js +++ b/src/config/botConfig.js @@ -28,7 +28,6 @@ export const botConfig = { { name: "/help", type: 3 }, { name: "zen vibes", type: 2 }, { name: "itay100k build this bot", type: 5 }, - { name: "https://discord.gg/3znmrNRS7x", type: 0 }, ], }, diff --git a/src/config/changelog.js b/src/config/changelog.js index a72307c84f..e9893097c0 100644 --- a/src/config/changelog.js +++ b/src/config/changelog.js @@ -1,4 +1,12 @@ export const changelog = [ + { + 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', From 09ddf0d17cc35fb389a0798f78b6ce81b635bfcc Mon Sep 17 00:00:00 2001 From: Itay100K Date: Mon, 4 May 2026 15:09:21 +0300 Subject: [PATCH 028/275] Add /antinsfw command with Sightengine image scanning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New /antinsfw command: enable/disable, config (action/log/threshold), exempt channels & roles, custom keyword management, status view - AntiNsfwService: domain blocking, keyword filtering, Sightengine nudity-2.0 API scanning for images/videos - Hooks into messageCreate โ€” flagged messages are deleted before autoresponder/leveling run - Actions: delete, warn (DM), timeout, kick, ban - Added SIGHTENGINE_API_USER / SIGHTENGINE_API_SECRET to .env.example Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 6 + src/commands/Moderation/antinsfw.js | 333 ++++++++++++++++++++++++++++ src/config/changelog.js | 9 + src/events/messageCreate.js | 4 + src/services/antiNsfwService.js | 217 ++++++++++++++++++ 5 files changed, 569 insertions(+) create mode 100644 src/commands/Moderation/antinsfw.js create mode 100644 src/services/antiNsfwService.js diff --git a/.env.example b/.env.example index 4f08ac030e..66a4760689 100644 --- a/.env.example +++ b/.env.example @@ -48,3 +48,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/src/commands/Moderation/antinsfw.js b/src/commands/Moderation/antinsfw.js new file mode 100644 index 0000000000..c28b187a07 --- /dev/null +++ b/src/commands/Moderation/antinsfw.js @@ -0,0 +1,333 @@ +import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; +import { createEmbed, errorEmbed, successEmbed } from '../../utils/embeds.js'; +import { logger } from '../../utils/logger.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { AntiNsfwService } from '../../services/antiNsfwService.js'; + +export default { + data: new SlashCommandBuilder() + .setName('antinsfw') + .setDescription('Configure automatic NSFW content detection and removal') + .addSubcommand(sub => + sub.setName('enable') + .setDescription('Enable anti-NSFW scanning in this server') + ) + .addSubcommand(sub => + sub.setName('disable') + .setDescription('Disable anti-NSFW scanning in this server') + ) + .addSubcommand(sub => + sub.setName('config') + .setDescription('Configure anti-NSFW settings') + .addStringOption(o => + o.setName('action') + .setDescription('What to do when NSFW content is detected') + .setRequired(true) + .addChoices( + { name: 'Delete only', value: 'delete' }, + { name: 'Delete + DM warn', value: 'warn' }, + { name: 'Delete + Timeout', value: 'timeout' }, + { name: 'Delete + Kick', value: 'kick' }, + { name: 'Delete + Ban', value: 'ban' }, + ) + ) + .addChannelOption(o => + o.setName('log_channel') + .setDescription('Channel to log NSFW violations (leave blank to disable logging)') + ) + .addIntegerOption(o => + o.setName('timeout_minutes') + .setDescription('Timeout duration in minutes when action is "timeout" (default: 5)') + .setMinValue(1) + .setMaxValue(40320) + ) + .addBooleanOption(o => + o.setName('image_scanning') + .setDescription('Scan images/videos via Sightengine API (requires API keys in .env)') + ) + .addNumberOption(o => + o.setName('nudity_threshold') + .setDescription('Confidence threshold for image flagging 0.0โ€“1.0 (default: 0.75)') + .setMinValue(0.1) + .setMaxValue(1.0) + ) + ) + .addSubcommand(sub => + sub.setName('status') + .setDescription('Show current anti-NSFW configuration') + ) + .addSubcommand(sub => + sub.setName('exempt') + .setDescription('Add or remove an exempt channel or role') + .addStringOption(o => + o.setName('action') + .setDescription('Add or remove an exemption') + .setRequired(true) + .addChoices( + { name: 'Add channel', value: 'add_channel' }, + { name: 'Remove channel', value: 'remove_channel' }, + { name: 'Add role', value: 'add_role' }, + { name: 'Remove role', value: 'remove_role' }, + ) + ) + .addChannelOption(o => + o.setName('channel') + .setDescription('Channel to exempt (for add/remove channel)') + ) + .addRoleOption(o => + o.setName('role') + .setDescription('Role to exempt (for add/remove role)') + ) + ) + .addSubcommand(sub => + sub.setName('words') + .setDescription('Manage custom NSFW keywords') + .addStringOption(o => + o.setName('action') + .setDescription('Add, remove, or list custom words') + .setRequired(true) + .addChoices( + { name: 'Add word', value: 'add' }, + { name: 'Remove word', value: 'remove' }, + { name: 'List words', value: 'list' }, + ) + ) + .addStringOption(o => + o.setName('word') + .setDescription('Word to add or remove') + ) + ) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild), + category: 'moderation', + + async execute(interaction, config, client) { + await InteractionHelper.safeDefer(interaction, { ephemeral: true }); + + const sub = interaction.options.getSubcommand(); + const guildId = interaction.guildId; + + try { + if (sub === 'enable') { + await AntiNsfwService.setConfig(client, guildId, { enabled: true }); + return InteractionHelper.safeEditReply(interaction, { + embeds: [successEmbed('Anti-NSFW Enabled', 'NSFW scanning is now active. Use `/antinsfw config` to set the action and log channel.')], + }); + } + + if (sub === 'disable') { + await AntiNsfwService.setConfig(client, guildId, { enabled: false }); + return InteractionHelper.safeEditReply(interaction, { + embeds: [successEmbed('Anti-NSFW Disabled', 'NSFW scanning has been turned off.')], + }); + } + + if (sub === 'config') { + const action = interaction.options.getString('action'); + const logChannel = interaction.options.getChannel('log_channel'); + const timeoutMinutes = interaction.options.getInteger('timeout_minutes'); + const imageScanning = interaction.options.getBoolean('image_scanning'); + const nudityThreshold = interaction.options.getNumber('nudity_threshold'); + + const updates = { action }; + if (logChannel !== null) updates.logChannelId = logChannel.id; + if (timeoutMinutes !== null) updates.timeoutDuration = timeoutMinutes * 60 * 1000; + if (imageScanning !== null) updates.imageScanning = imageScanning; + if (nudityThreshold !== null) updates.nudityThreshold = nudityThreshold; + + const updated = await AntiNsfwService.setConfig(client, guildId, updates); + + const lines = [ + `**Action:** ${updated.action}`, + updated.action === 'timeout' + ? `**Timeout duration:** ${Math.round(updated.timeoutDuration / 60000)} minutes` + : null, + `**Log channel:** ${updated.logChannelId ? `<#${updated.logChannelId}>` : 'none'}`, + `**Image scanning:** ${updated.imageScanning ? 'enabled' : 'disabled'}`, + updated.imageScanning + ? `**Nudity threshold:** ${updated.nudityThreshold}` + : null, + ].filter(Boolean).join('\n'); + + return InteractionHelper.safeEditReply(interaction, { + embeds: [successEmbed('Anti-NSFW Configured', lines)], + }); + } + + if (sub === 'status') { + const cfg = await AntiNsfwService.getConfig(client, guildId); + const apiKeysSet = !!(process.env.SIGHTENGINE_API_USER && process.env.SIGHTENGINE_API_SECRET); + + const lines = [ + `**Status:** ${cfg.enabled ? 'Enabled' : 'Disabled'}`, + `**Action:** ${cfg.action}`, + cfg.action === 'timeout' + ? `**Timeout duration:** ${Math.round(cfg.timeoutDuration / 60000)} minutes` + : null, + `**Log channel:** ${cfg.logChannelId ? `<#${cfg.logChannelId}>` : 'none'}`, + `**Image scanning:** ${cfg.imageScanning ? 'enabled' : 'disabled'} ${!apiKeysSet ? '*(API keys not set)*' : ''}`, + cfg.imageScanning ? `**Nudity threshold:** ${cfg.nudityThreshold}` : null, + `**Exempt channels:** ${cfg.exemptChannels.length ? cfg.exemptChannels.map(id => `<#${id}>`).join(', ') : 'none'}`, + `**Exempt roles:** ${cfg.exemptRoles.length ? cfg.exemptRoles.map(id => `<@&${id}>`).join(', ') : 'none'}`, + `**Custom words:** ${cfg.customWords.length} word${cfg.customWords.length !== 1 ? 's' : ''}`, + ].filter(Boolean).join('\n'); + + return InteractionHelper.safeEditReply(interaction, { + embeds: [createEmbed({ + title: 'Anti-NSFW Status', + description: lines, + color: cfg.enabled ? 'green' : 'gray', + })], + }); + } + + if (sub === 'exempt') { + const action = interaction.options.getString('action'); + const channel = interaction.options.getChannel('channel'); + const role = interaction.options.getRole('role'); + const cfg = await AntiNsfwService.getConfig(client, guildId); + + if (action === 'add_channel') { + if (!channel) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Missing Option', 'Please provide a channel to exempt.')], + }); + } + if (cfg.exemptChannels.includes(channel.id)) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Already Exempt', `${channel} is already exempt.`)], + }); + } + cfg.exemptChannels.push(channel.id); + await AntiNsfwService.setConfig(client, guildId, { exemptChannels: cfg.exemptChannels }); + return InteractionHelper.safeEditReply(interaction, { + embeds: [successEmbed('Exemption Added', `${channel} will no longer be scanned.`)], + }); + } + + if (action === 'remove_channel') { + if (!channel) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Missing Option', 'Please provide a channel to remove from exemptions.')], + }); + } + const filtered = cfg.exemptChannels.filter(id => id !== channel.id); + if (filtered.length === cfg.exemptChannels.length) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Not Found', `${channel} is not in the exempt list.`)], + }); + } + await AntiNsfwService.setConfig(client, guildId, { exemptChannels: filtered }); + return InteractionHelper.safeEditReply(interaction, { + embeds: [successEmbed('Exemption Removed', `${channel} will now be scanned.`)], + }); + } + + if (action === 'add_role') { + if (!role) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Missing Option', 'Please provide a role to exempt.')], + }); + } + if (cfg.exemptRoles.includes(role.id)) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Already Exempt', `${role} is already exempt.`)], + }); + } + cfg.exemptRoles.push(role.id); + await AntiNsfwService.setConfig(client, guildId, { exemptRoles: cfg.exemptRoles }); + return InteractionHelper.safeEditReply(interaction, { + embeds: [successEmbed('Exemption Added', `Members with ${role} will not be scanned.`)], + }); + } + + if (action === 'remove_role') { + if (!role) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Missing Option', 'Please provide a role to remove from exemptions.')], + }); + } + const filtered = cfg.exemptRoles.filter(id => id !== role.id); + if (filtered.length === cfg.exemptRoles.length) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Not Found', `${role} is not in the exempt list.`)], + }); + } + await AntiNsfwService.setConfig(client, guildId, { exemptRoles: filtered }); + return InteractionHelper.safeEditReply(interaction, { + embeds: [successEmbed('Exemption Removed', `Members with ${role} will now be scanned.`)], + }); + } + } + + if (sub === 'words') { + const action = interaction.options.getString('action'); + const word = interaction.options.getString('word')?.toLowerCase().trim(); + const cfg = await AntiNsfwService.getConfig(client, guildId); + + if (action === 'add') { + if (!word) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Missing Option', 'Please provide a word to add.')], + }); + } + if (word.length > 50) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Too Long', 'Word must be 50 characters or fewer.')], + }); + } + if (AntiNsfwService.getBaseWords().includes(word)) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Already Blocked', `"${word}" is already in the built-in word list.`)], + }); + } + if (cfg.customWords.includes(word)) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Duplicate', `"${word}" is already in your custom list.`)], + }); + } + cfg.customWords.push(word); + await AntiNsfwService.setConfig(client, guildId, { customWords: cfg.customWords }); + return InteractionHelper.safeEditReply(interaction, { + embeds: [successEmbed('Word Added', `"${word}" added to the NSFW keyword list.`)], + }); + } + + if (action === 'remove') { + if (!word) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Missing Option', 'Please provide a word to remove.')], + }); + } + const filtered = cfg.customWords.filter(w => w !== word); + if (filtered.length === cfg.customWords.length) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Not Found', `"${word}" is not in your custom word list.`)], + }); + } + await AntiNsfwService.setConfig(client, guildId, { customWords: filtered }); + return InteractionHelper.safeEditReply(interaction, { + embeds: [successEmbed('Word Removed', `"${word}" removed from the NSFW keyword list.`)], + }); + } + + if (action === 'list') { + const baseWords = AntiNsfwService.getBaseWords(); + const lines = [ + `**Built-in words (${baseWords.length}):** ${baseWords.join(', ')}`, + cfg.customWords.length + ? `**Custom words (${cfg.customWords.length}):** ${cfg.customWords.join(', ')}` + : `**Custom words:** none`, + ].join('\n\n'); + return InteractionHelper.safeEditReply(interaction, { + embeds: [createEmbed({ title: 'NSFW Keyword Lists', description: lines, color: 'blue' })], + }); + } + } + } catch (error) { + logger.error('antinsfw command error:', error); + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Error', 'Something went wrong. Try again later.')], + }); + } + }, +}; diff --git a/src/config/changelog.js b/src/config/changelog.js index e9893097c0..30c18ff28c 100644 --- a/src/config/changelog.js +++ b/src/config/changelog.js @@ -1,4 +1,13 @@ export const changelog = [ + { + 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`.' }, + ], + }, { version: '2.4.3', date: '2026-05-03', diff --git a/src/events/messageCreate.js b/src/events/messageCreate.js index 6f6cb10733..1aab57fc87 100644 --- a/src/events/messageCreate.js +++ b/src/events/messageCreate.js @@ -9,6 +9,7 @@ import { getLevelingConfig, getUserLevelData } from '../services/leveling.js'; import { addXp } from '../services/xpSystem.js'; import { checkRateLimit } from '../utils/rateLimiter.js'; import { AutoresponderService } from '../services/autoresponderService.js'; +import { AntiNsfwService } from '../services/antiNsfwService.js'; const MESSAGE_XP_RATE_LIMIT_ATTEMPTS = 12; const MESSAGE_XP_RATE_LIMIT_WINDOW_MS = 10000; @@ -25,6 +26,9 @@ export default { return; } + const flagged = await AntiNsfwService.checkMessage(client, message); + if (flagged) return; + await AutoresponderService.check(client, message); await handleLeveling(message, client); } catch (error) { 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]; + }, +}; From 9557002963778e7a9614142e1821d4d643d21ceb Mon Sep 17 00:00:00 2001 From: Itay100K Date: Mon, 4 May 2026 15:40:56 +0300 Subject: [PATCH 029/275] Add antinsfw to help menu Moderation category description Co-Authored-By: Claude Sonnet 4.6 --- src/commands/Core/help.js | 2 +- src/config/changelog.js | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/commands/Core/help.js b/src/commands/Core/help.js index 3fc2fad9ab..d97ac5e978 100644 --- a/src/commands/Core/help.js +++ b/src/commands/Core/help.js @@ -21,7 +21,7 @@ const HELP_MENU_TIMEOUT_MS = 5 * 60 * 1000; const CATEGORY_INFO = { Core: { icon: "โ„น๏ธ", desc: "Core bot commands and information" }, - Moderation: { icon: "๐Ÿ›ก๏ธ", desc: "Server moderation, user management, and enforcement tools" }, + Moderation: { icon: "๐Ÿ›ก๏ธ", desc: "Server moderation, user management, enforcement tools, and NSFW content detection" }, Fun: { icon: "๐ŸŽฎ", desc: "Games, entertainment, and interactive commands" }, Leveling: { icon: "๐Ÿ“Š", desc: "User levels, XP system, and progression tracking" }, Utility: { icon: "๐Ÿ”ง", desc: "Useful tools and server utilities" }, diff --git a/src/config/changelog.js b/src/config/changelog.js index 30c18ff28c..593f41d9ed 100644 --- a/src/config/changelog.js +++ b/src/config/changelog.js @@ -6,6 +6,7 @@ export const changelog = [ { 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`' }, ], }, { From 14069c09e3a575d53f800391d72be28c91b853db Mon Sep 17 00:00:00 2001 From: Itay100K Date: Mon, 4 May 2026 16:06:30 +0300 Subject: [PATCH 030/275] Add /servers command (owner only) Lists all servers the bot is in with member counts and IDs, sorted by size. Restricted to OWNER_IDS; returns an ephemeral error for anyone else. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/Core/servers.js | 71 ++++++++++++++++++++++++++++++++++++ src/config/changelog.js | 7 ++++ 2 files changed, 78 insertions(+) create mode 100644 src/commands/Core/servers.js diff --git a/src/commands/Core/servers.js b/src/commands/Core/servers.js new file mode 100644 index 0000000000..3bd01c77cf --- /dev/null +++ b/src/commands/Core/servers.js @@ -0,0 +1,71 @@ +import { SlashCommandBuilder } from 'discord.js'; +import { createEmbed, errorEmbed } from '../../utils/embeds.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { botConfig } from '../../config/botConfig.js'; +import { logger } from '../../utils/logger.js'; + +export default { + data: new SlashCommandBuilder() + .setName('servers') + .setDescription('List all servers the bot is currently in (owner only)'), + category: 'core', + + async execute(interaction, config, client) { + if (!botConfig.commands.owners.includes(interaction.user.id)) { + return InteractionHelper.safeReply(interaction, { + embeds: [errorEmbed('Access Denied', 'This command is restricted to the bot owner.')], + ephemeral: true, + }); + } + + try { + await InteractionHelper.safeDefer(interaction, { ephemeral: true }); + + const guilds = [...client.guilds.cache.values()].sort((a, b) => b.memberCount - a.memberCount); + const totalMembers = guilds.reduce((sum, g) => sum + g.memberCount, 0); + + const CHUNK = 20; + const pages = []; + for (let i = 0; i < guilds.length; i += CHUNK) { + pages.push(guilds.slice(i, i + CHUNK)); + } + + const embeds = pages.map((chunk, pageIndex) => { + const lines = chunk.map((g, i) => { + const num = pageIndex * CHUNK + i + 1; + return `**${num}.** ${g.name}\nโ†ณ \`${g.id}\` ยท ๐Ÿ‘ฅ ${g.memberCount.toLocaleString()} members`; + }).join('\n\n'); + + return createEmbed({ + title: `๐ŸŒ Servers (${pageIndex + 1}/${pages.length})`, + description: lines || 'No servers.', + color: 'primary', + }).addFields( + pageIndex === 0 + ? [{ name: '๐Ÿ“Š Summary', value: `**Total servers:** ${guilds.length}\n**Total members:** ${totalMembers.toLocaleString()}`, inline: false }] + : [] + ); + }); + + if (embeds.length === 0) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [createEmbed({ title: '๐ŸŒ Servers', description: 'The bot is not in any servers.', color: 'warning' })], + }); + } + + // Discord allows up to 10 embeds per message + await InteractionHelper.safeEditReply(interaction, { embeds: embeds.slice(0, 10) }); + + if (embeds.length > 10) { + for (let i = 10; i < embeds.length; i += 10) { + await interaction.followUp({ embeds: embeds.slice(i, i + 10), ephemeral: true }); + } + } + } catch (error) { + logger.error('servers command error:', error); + await InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Error', 'Failed to fetch server list.')], + }); + } + }, +}; diff --git a/src/config/changelog.js b/src/config/changelog.js index 593f41d9ed..edbff469af 100644 --- a/src/config/changelog.js +++ b/src/config/changelog.js @@ -1,4 +1,11 @@ export const changelog = [ + { + 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' }, + ], + }, { version: '2.5.0', date: '2026-05-04', From 42eee03b00d97767c69e1e9b292552734c8cefab Mon Sep 17 00:00:00 2001 From: Itay100K Date: Mon, 4 May 2026 16:11:18 +0300 Subject: [PATCH 031/275] Add Leave Server button to /servers command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking the ๐Ÿšช button opens a modal to enter a server ID. On submit the bot leaves that guild and confirms with the server name and member count. Owner-only at both button and modal level. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/Core/servers.js | 15 ++++++++-- src/config/changelog.js | 1 + src/interactions/buttons/servers.js | 32 ++++++++++++++++++++ src/interactions/modals/servers.js | 45 +++++++++++++++++++++++++++++ 4 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 src/interactions/buttons/servers.js create mode 100644 src/interactions/modals/servers.js diff --git a/src/commands/Core/servers.js b/src/commands/Core/servers.js index 3bd01c77cf..71360a5ce0 100644 --- a/src/commands/Core/servers.js +++ b/src/commands/Core/servers.js @@ -1,4 +1,4 @@ -import { SlashCommandBuilder } from 'discord.js'; +import { SlashCommandBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; import { createEmbed, errorEmbed } from '../../utils/embeds.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { botConfig } from '../../config/botConfig.js'; @@ -53,8 +53,19 @@ export default { }); } + const leaveButton = new ButtonBuilder() + .setCustomId('servers-leave') + .setLabel('Leave a Server') + .setStyle(ButtonStyle.Danger) + .setEmoji('๐Ÿšช'); + + const buttonRow = new ActionRowBuilder().addComponents(leaveButton); + // Discord allows up to 10 embeds per message - await InteractionHelper.safeEditReply(interaction, { embeds: embeds.slice(0, 10) }); + await InteractionHelper.safeEditReply(interaction, { + embeds: embeds.slice(0, 10), + components: [buttonRow], + }); if (embeds.length > 10) { for (let i = 10; i < embeds.length; i += 10) { diff --git a/src/config/changelog.js b/src/config/changelog.js index edbff469af..de02a91f57 100644 --- a/src/config/changelog.js +++ b/src/config/changelog.js @@ -4,6 +4,7 @@ export const changelog = [ 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' }, ], }, { diff --git a/src/interactions/buttons/servers.js b/src/interactions/buttons/servers.js new file mode 100644 index 0000000000..73820461a3 --- /dev/null +++ b/src/interactions/buttons/servers.js @@ -0,0 +1,32 @@ +import { ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder } from 'discord.js'; +import { botConfig } from '../../config/botConfig.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { errorEmbed } from '../../utils/embeds.js'; + +export default { + name: 'servers-leave', + async execute(interaction) { + if (!botConfig.commands.owners.includes(interaction.user.id)) { + return InteractionHelper.safeReply(interaction, { + embeds: [errorEmbed('Access Denied', 'This button is restricted to the bot owner.')], + ephemeral: true, + }); + } + + const modal = new ModalBuilder() + .setCustomId('servers-leave-modal') + .setTitle('Leave Server'); + + const serverIdInput = new TextInputBuilder() + .setCustomId('server_id') + .setLabel('Server ID') + .setStyle(TextInputStyle.Short) + .setPlaceholder('Paste the server ID here') + .setRequired(true) + .setMinLength(17) + .setMaxLength(20); + + modal.addComponents(new ActionRowBuilder().addComponents(serverIdInput)); + await interaction.showModal(modal); + }, +}; diff --git a/src/interactions/modals/servers.js b/src/interactions/modals/servers.js new file mode 100644 index 0000000000..3df8726d7a --- /dev/null +++ b/src/interactions/modals/servers.js @@ -0,0 +1,45 @@ +import { botConfig } from '../../config/botConfig.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { errorEmbed, successEmbed } from '../../utils/embeds.js'; +import { logger } from '../../utils/logger.js'; + +export default { + name: 'servers-leave-modal', + async execute(interaction, client) { + if (!botConfig.commands.owners.includes(interaction.user.id)) { + return InteractionHelper.safeReply(interaction, { + embeds: [errorEmbed('Access Denied', 'This action is restricted to the bot owner.')], + ephemeral: true, + }); + } + + const serverId = interaction.fields.getTextInputValue('server_id').trim(); + + const guild = client.guilds.cache.get(serverId); + if (!guild) { + return InteractionHelper.safeReply(interaction, { + embeds: [errorEmbed('Server Not Found', `No server with ID \`${serverId}\` was found. The bot may not be in that server.`)], + ephemeral: true, + }); + } + + const guildName = guild.name; + const memberCount = guild.memberCount; + + try { + await guild.leave(); + logger.info(`Bot left guild "${guildName}" (${serverId}) โ€” requested by owner ${interaction.user.tag}`); + + return InteractionHelper.safeReply(interaction, { + embeds: [successEmbed('Left Server', `Successfully left **${guildName}** (\`${serverId}\`) โ€” ${memberCount.toLocaleString()} members.`)], + ephemeral: true, + }); + } catch (error) { + logger.error(`Failed to leave guild ${serverId}:`, error); + return InteractionHelper.safeReply(interaction, { + embeds: [errorEmbed('Failed to Leave', `Could not leave **${guildName}**. ${error.message}`)], + ephemeral: true, + }); + } + }, +}; From 733f9b74e6efc1456fd14cb38b0eea2021f31f2e Mon Sep 17 00:00:00 2001 From: Itay100K Date: Mon, 4 May 2026 16:33:18 +0300 Subject: [PATCH 032/275] Add 'Add to Server' invite button to /servers command Co-Authored-By: Claude Sonnet 4.6 --- src/commands/Core/servers.js | 10 +++++++++- src/config/changelog.js | 7 +++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/commands/Core/servers.js b/src/commands/Core/servers.js index 71360a5ce0..4d36e9a479 100644 --- a/src/commands/Core/servers.js +++ b/src/commands/Core/servers.js @@ -53,13 +53,21 @@ export default { }); } + const inviteUrl = `https://discord.com/oauth2/authorize?client_id=${client.user.id}&permissions=8&scope=bot%20applications.commands`; + const leaveButton = new ButtonBuilder() .setCustomId('servers-leave') .setLabel('Leave a Server') .setStyle(ButtonStyle.Danger) .setEmoji('๐Ÿšช'); - const buttonRow = new ActionRowBuilder().addComponents(leaveButton); + const addButton = new ButtonBuilder() + .setLabel('Add to Server') + .setURL(inviteUrl) + .setStyle(ButtonStyle.Link) + .setEmoji('โž•'); + + const buttonRow = new ActionRowBuilder().addComponents(leaveButton, addButton); // Discord allows up to 10 embeds per message await InteractionHelper.safeEditReply(interaction, { diff --git a/src/config/changelog.js b/src/config/changelog.js index de02a91f57..2ae0cc975e 100644 --- a/src/config/changelog.js +++ b/src/config/changelog.js @@ -1,4 +1,11 @@ export const changelog = [ + { + 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', From acdecfabe9548de0f86ea95333ff287ff4cd3b7f Mon Sep 17 00:00:00 2001 From: Itay100K Date: Mon, 4 May 2026 22:10:56 +0300 Subject: [PATCH 033/275] Add /edit command with category-based visual edits Co-Authored-By: Claude Sonnet 4.6 --- src/commands/Fun/edit.js | 74 ++++++++++++++++++++++++++++++++++++++++ src/config/changelog.js | 7 ++++ 2 files changed, 81 insertions(+) create mode 100644 src/commands/Fun/edit.js diff --git a/src/commands/Fun/edit.js b/src/commands/Fun/edit.js new file mode 100644 index 0000000000..6c5e838f59 --- /dev/null +++ b/src/commands/Fun/edit.js @@ -0,0 +1,74 @@ +import { SlashCommandBuilder } from 'discord.js'; +import { createEmbed, errorEmbed } from '../../utils/embeds.js'; +import { logger } from '../../utils/logger.js'; +import { handleInteractionError } from '../../utils/errorHandler.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; + +const CATEGORIES = { + anime: { label: 'Anime', subreddit: 'animeedits', emoji: '๐ŸŽŒ' }, + cars: { label: 'Cars', subreddit: 'carporn', emoji: '๐Ÿš—' }, + nature: { label: 'Nature', subreddit: 'EarthPorn', emoji: '๐ŸŒ' }, + cities: { label: 'Cities', subreddit: 'CityPorn', emoji: '๐ŸŒ†' }, + animals: { label: 'Animals', subreddit: 'NatureIsFucked', emoji: '๐Ÿพ' }, + gaming: { label: 'Gaming', subreddit: 'gaming', emoji: '๐ŸŽฎ' }, + sports: { label: 'Sports', subreddit: 'sports', emoji: 'โšฝ' }, + space: { label: 'Space', subreddit: 'spaceporn', emoji: '๐Ÿš€' }, +}; + +export default { + data: new SlashCommandBuilder() + .setName('edit') + .setDescription('Get a random edit from a category') + .addStringOption(option => + option + .setName('category') + .setDescription('The category of edit to fetch') + .setRequired(true) + .addChoices( + ...Object.entries(CATEGORIES).map(([value, { label, emoji }]) => ({ + name: `${emoji} ${label}`, + value, + })) + ) + ), + + async execute(interaction) { + try { + await InteractionHelper.safeDefer(interaction); + + const key = interaction.options.getString('category'); + const { label, subreddit, emoji } = CATEGORIES[key]; + + const res = await fetch(`https://meme-api.com/gimme/${subreddit}`); + + if (!res.ok) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Failed', `Could not fetch a ${label} edit right now. Try again later.`)], + }); + } + + const data = await res.json(); + + if (data.nsfw) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('NSFW', 'The fetched post was NSFW. Try again!')], + }); + } + + const embed = createEmbed({ + title: `${emoji} ${data.title}`, + color: 'blurple', + footer: { text: `r/${data.subreddit} โ€ข ๐Ÿ‘ ${data.ups}` }, + timestamp: false, + }); + embed.setImage(data.url); + embed.setURL(data.postLink); + + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); + logger.info(`Edit: ${interaction.user.id} got ${label} edit from r/${data.subreddit}`); + } catch (error) { + logger.error('Edit command error:', error); + await handleInteractionError(interaction, error, { commandName: 'edit' }); + } + }, +}; diff --git a/src/config/changelog.js b/src/config/changelog.js index 2ae0cc975e..2932f1652f 100644 --- a/src/config/changelog.js +++ b/src/config/changelog.js @@ -1,4 +1,11 @@ export const changelog = [ + { + 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', From a8291cf0483e8a403fb1cee2d314f0d233232e6c Mon Sep 17 00:00:00 2001 From: Itay100K Date: Mon, 4 May 2026 22:14:49 +0300 Subject: [PATCH 034/275] Switch /edit to fetch videos from Reddit JSON API Co-Authored-By: Claude Sonnet 4.6 --- src/commands/Fun/edit.js | 48 ++++++++++++++++++++++++---------------- 1 file changed, 29 insertions(+), 19 deletions(-) diff --git a/src/commands/Fun/edit.js b/src/commands/Fun/edit.js index 6c5e838f59..abc6808ba2 100644 --- a/src/commands/Fun/edit.js +++ b/src/commands/Fun/edit.js @@ -5,20 +5,20 @@ import { handleInteractionError } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; const CATEGORIES = { - anime: { label: 'Anime', subreddit: 'animeedits', emoji: '๐ŸŽŒ' }, - cars: { label: 'Cars', subreddit: 'carporn', emoji: '๐Ÿš—' }, - nature: { label: 'Nature', subreddit: 'EarthPorn', emoji: '๐ŸŒ' }, - cities: { label: 'Cities', subreddit: 'CityPorn', emoji: '๐ŸŒ†' }, - animals: { label: 'Animals', subreddit: 'NatureIsFucked', emoji: '๐Ÿพ' }, - gaming: { label: 'Gaming', subreddit: 'gaming', emoji: '๐ŸŽฎ' }, - sports: { label: 'Sports', subreddit: 'sports', emoji: 'โšฝ' }, - space: { label: 'Space', subreddit: 'spaceporn', emoji: '๐Ÿš€' }, + anime: { label: 'Anime', subreddit: 'animeedits', emoji: '๐ŸŽŒ' }, + cars: { label: 'Cars', subreddit: 'CarVideos', emoji: '๐Ÿš—' }, + nature: { label: 'Nature', subreddit: 'NatureGifs', emoji: '๐ŸŒ' }, + gaming: { label: 'Gaming', subreddit: 'GamePhysics', emoji: '๐ŸŽฎ' }, + sports: { label: 'Sports', subreddit: 'sports', emoji: 'โšฝ' }, + space: { label: 'Space', subreddit: 'Spacegifs', emoji: '๐Ÿš€' }, + satisfying: { label: 'Satisfying', subreddit: 'perfectloops', emoji: 'โœจ' }, + funny: { label: 'Funny', subreddit: 'funny', emoji: '๐Ÿ˜‚' }, }; export default { data: new SlashCommandBuilder() .setName('edit') - .setDescription('Get a random edit from a category') + .setDescription('Get a random video edit from a category') .addStringOption(option => option .setName('category') @@ -39,7 +39,9 @@ export default { const key = interaction.options.getString('category'); const { label, subreddit, emoji } = CATEGORIES[key]; - const res = await fetch(`https://meme-api.com/gimme/${subreddit}`); + const res = await fetch(`https://www.reddit.com/r/${subreddit}/hot.json?limit=50`, { + headers: { 'User-Agent': 'ZenBot/1.0' }, + }); if (!res.ok) { return InteractionHelper.safeEditReply(interaction, { @@ -47,25 +49,33 @@ export default { }); } - const data = await res.json(); + const json = await res.json(); + const posts = json.data.children + .map(p => p.data) + .filter(p => p.is_video && !p.over_18); - if (data.nsfw) { + if (posts.length === 0) { return InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('NSFW', 'The fetched post was NSFW. Try again!')], + embeds: [errorEmbed('No Videos Found', `No video edits found in ${emoji} ${label} right now. Try again later.`)], }); } + const post = posts[Math.floor(Math.random() * posts.length)]; + const embed = createEmbed({ - title: `${emoji} ${data.title}`, + title: `${emoji} ${post.title}`, color: 'blurple', - footer: { text: `r/${data.subreddit} โ€ข ๐Ÿ‘ ${data.ups}` }, + footer: { text: `r/${post.subreddit} โ€ข ๐Ÿ‘ ${post.ups.toLocaleString()}` }, timestamp: false, }); - embed.setImage(data.url); - embed.setURL(data.postLink); + embed.setURL(`https://reddit.com${post.permalink}`); + + await InteractionHelper.safeEditReply(interaction, { + content: `https://reddit.com${post.permalink}`, + embeds: [embed], + }); - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - logger.info(`Edit: ${interaction.user.id} got ${label} edit from r/${data.subreddit}`); + logger.info(`Edit: ${interaction.user.id} got ${label} video from r/${post.subreddit}`); } catch (error) { logger.error('Edit command error:', error); await handleInteractionError(interaction, error, { commandName: 'edit' }); From 73a558cefcf85ddd6fdfe695ca4867826a90842a Mon Sep 17 00:00:00 2001 From: Itay100K Date: Mon, 4 May 2026 22:19:36 +0300 Subject: [PATCH 035/275] Rework /edit to send random CapCut edit templates Co-Authored-By: Claude Sonnet 4.6 --- src/commands/Fun/edit.js | 73 +++++++--------------------------------- 1 file changed, 12 insertions(+), 61 deletions(-) diff --git a/src/commands/Fun/edit.js b/src/commands/Fun/edit.js index abc6808ba2..67e2ef9f3f 100644 --- a/src/commands/Fun/edit.js +++ b/src/commands/Fun/edit.js @@ -1,81 +1,32 @@ import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed } from '../../utils/embeds.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'; -const CATEGORIES = { - anime: { label: 'Anime', subreddit: 'animeedits', emoji: '๐ŸŽŒ' }, - cars: { label: 'Cars', subreddit: 'CarVideos', emoji: '๐Ÿš—' }, - nature: { label: 'Nature', subreddit: 'NatureGifs', emoji: '๐ŸŒ' }, - gaming: { label: 'Gaming', subreddit: 'GamePhysics', emoji: '๐ŸŽฎ' }, - sports: { label: 'Sports', subreddit: 'sports', emoji: 'โšฝ' }, - space: { label: 'Space', subreddit: 'Spacegifs', emoji: '๐Ÿš€' }, - satisfying: { label: 'Satisfying', subreddit: 'perfectloops', emoji: 'โœจ' }, - funny: { label: 'Funny', subreddit: 'funny', emoji: '๐Ÿ˜‚' }, -}; +const TEMPLATES = [ + 'https://www.capcut.com/template-detail/7581489168228830469', + 'https://www.capcut.com/tv2/ZSHKMDrGU/', +]; export default { data: new SlashCommandBuilder() .setName('edit') - .setDescription('Get a random video edit from a category') - .addStringOption(option => - option - .setName('category') - .setDescription('The category of edit to fetch') - .setRequired(true) - .addChoices( - ...Object.entries(CATEGORIES).map(([value, { label, emoji }]) => ({ - name: `${emoji} ${label}`, - value, - })) - ) - ), + .setDescription('Get a random CapCut edit template'), async execute(interaction) { try { - await InteractionHelper.safeDefer(interaction); - - const key = interaction.options.getString('category'); - const { label, subreddit, emoji } = CATEGORIES[key]; - - const res = await fetch(`https://www.reddit.com/r/${subreddit}/hot.json?limit=50`, { - headers: { 'User-Agent': 'ZenBot/1.0' }, - }); - - if (!res.ok) { - return InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('Failed', `Could not fetch a ${label} edit right now. Try again later.`)], - }); - } - - const json = await res.json(); - const posts = json.data.children - .map(p => p.data) - .filter(p => p.is_video && !p.over_18); - - if (posts.length === 0) { - return InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('No Videos Found', `No video edits found in ${emoji} ${label} right now. Try again later.`)], - }); - } - - const post = posts[Math.floor(Math.random() * posts.length)]; + const template = TEMPLATES[Math.floor(Math.random() * TEMPLATES.length)]; const embed = createEmbed({ - title: `${emoji} ${post.title}`, + title: '๐ŸŽฌ Random Edit Template', + description: `Here's a CapCut template for you!\n\n๐Ÿ”— [Open Template](${template})`, color: 'blurple', - footer: { text: `r/${post.subreddit} โ€ข ๐Ÿ‘ ${post.ups.toLocaleString()}` }, - timestamp: false, - }); - embed.setURL(`https://reddit.com${post.permalink}`); - - await InteractionHelper.safeEditReply(interaction, { - content: `https://reddit.com${post.permalink}`, - embeds: [embed], + footer: { text: 'Click the link to open in CapCut' }, }); - logger.info(`Edit: ${interaction.user.id} got ${label} video from r/${post.subreddit}`); + await InteractionHelper.safeReply(interaction, { embeds: [embed] }); + logger.info(`Edit: ${interaction.user.id} got template ${template}`); } catch (error) { logger.error('Edit command error:', error); await handleInteractionError(interaction, error, { commandName: 'edit' }); From 1a149a825388405cea242e48667067de15c38613 Mon Sep 17 00:00:00 2001 From: Itay100K Date: Mon, 4 May 2026 22:33:18 +0300 Subject: [PATCH 036/275] Add /8ball, /rps, /roast, and /snipe commands Co-Authored-By: Claude Sonnet 4.6 --- src/commands/Fun/8ball.js | 50 +++++++++++++++++++++++++++++++++++++ src/commands/Fun/roast.js | 49 ++++++++++++++++++++++++++++++++++++ src/commands/Fun/rps.js | 50 +++++++++++++++++++++++++++++++++++++ src/commands/Fun/snipe.js | 37 +++++++++++++++++++++++++++ src/config/changelog.js | 10 ++++++++ src/events/messageDelete.js | 10 ++++++++ src/utils/snipeCache.js | 2 ++ 7 files changed, 208 insertions(+) create mode 100644 src/commands/Fun/8ball.js create mode 100644 src/commands/Fun/roast.js create mode 100644 src/commands/Fun/rps.js create mode 100644 src/commands/Fun/snipe.js create mode 100644 src/utils/snipeCache.js diff --git a/src/commands/Fun/8ball.js b/src/commands/Fun/8ball.js new file mode 100644 index 0000000000..0f544088dd --- /dev/null +++ b/src/commands/Fun/8ball.js @@ -0,0 +1,50 @@ +import { SlashCommandBuilder } from 'discord.js'; +import { createEmbed } from '../../utils/embeds.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; + +const RESPONSES = [ + { text: 'It is certain.', color: 'success' }, + { text: 'It is decidedly so.', color: 'success' }, + { text: 'Without a doubt.', color: 'success' }, + { text: 'Yes, definitely.', color: 'success' }, + { text: 'You may rely on it.', color: 'success' }, + { text: 'As I see it, yes.', color: 'success' }, + { text: 'Most likely.', color: 'success' }, + { text: 'Outlook good.', color: 'success' }, + { text: 'Yes.', color: 'success' }, + { text: 'Signs point to yes.', color: 'success' }, + { text: 'Reply hazy, try again.', color: 'warning' }, + { text: 'Ask again later.', color: 'warning' }, + { text: 'Better not tell you now.', color: 'warning' }, + { text: 'Cannot predict now.', color: 'warning' }, + { text: 'Concentrate and ask again.', color: 'warning' }, + { text: "Don't count on it.", color: 'error' }, + { text: 'My reply is no.', color: 'error' }, + { text: 'My sources say no.', color: 'error' }, + { text: 'Outlook not so good.', color: 'error' }, + { text: 'Very doubtful.', color: 'error' }, +]; + +export default { + data: new SlashCommandBuilder() + .setName('8ball') + .setDescription('Ask the magic 8-ball a question') + .addStringOption(opt => + opt.setName('question').setDescription('Your question').setRequired(true) + ), + + async execute(interaction) { + const question = interaction.options.getString('question'); + const { text, color } = RESPONSES[Math.floor(Math.random() * RESPONSES.length)]; + + const embed = createEmbed({ + title: '๐ŸŽฑ Magic 8-Ball', + color, + }).addFields( + { name: 'โ“ Question', value: question }, + { name: '๐ŸŽฑ Answer', value: `**${text}**` } + ); + + await InteractionHelper.safeReply(interaction, { embeds: [embed] }); + }, +}; diff --git a/src/commands/Fun/roast.js b/src/commands/Fun/roast.js new file mode 100644 index 0000000000..6352ed7297 --- /dev/null +++ b/src/commands/Fun/roast.js @@ -0,0 +1,49 @@ +import { SlashCommandBuilder } from 'discord.js'; +import { createEmbed } from '../../utils/embeds.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; + +const ROASTS = [ + "I'd roast you, but my mom said I'm not allowed to burn trash.", + "You're the reason the gene pool needs a lifeguard.", + "I'd agree with you, but then we'd both be wrong.", + "You're not stupid; you just have bad luck thinking.", + "I've seen better looking faces on a clock.", + "If brains were dynamite, you wouldn't have enough to blow your hat off.", + "You're proof that even God makes mistakes sometimes.", + "I'd give you a nasty look, but you already have one.", + "Some day you'll go far โ€” and I hope you stay there.", + "You're the human equivalent of a participation trophy.", + "I'd call you a tool, but even tools are useful.", + "You bring everyone so much joy... when you leave the room.", + "I've met some real characters, and you're definitely something.", + "If you were any less impressive, you'd be a footnote.", + "Your secrets are always safe with me โ€” I never listen when you talk.", + "You have something on your chin. No, the third one down.", + "I'd explain it to you, but I don't have the time or the crayons.", + "You're like a cloud โ€” when you disappear, it's a beautiful day.", + "Light travels faster than sound, which is why you seemed bright until you spoke.", + "I thought of you today. It reminded me to take out the trash.", +]; + +export default { + data: new SlashCommandBuilder() + .setName('roast') + .setDescription('Roast a user') + .addUserOption(opt => + opt.setName('user').setDescription('The user to roast').setRequired(true) + ), + + async execute(interaction) { + const target = interaction.options.getUser('user'); + const roast = ROASTS[Math.floor(Math.random() * ROASTS.length)]; + + const embed = createEmbed({ + title: `๐Ÿ”ฅ Roasting ${target.username}`, + description: roast, + color: 'error', + footer: { text: `Requested by ${interaction.user.username}` }, + }); + + await InteractionHelper.safeReply(interaction, { embeds: [embed] }); + }, +}; diff --git a/src/commands/Fun/rps.js b/src/commands/Fun/rps.js new file mode 100644 index 0000000000..8b3b537a96 --- /dev/null +++ b/src/commands/Fun/rps.js @@ -0,0 +1,50 @@ +import { SlashCommandBuilder } from 'discord.js'; +import { createEmbed } from '../../utils/embeds.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; + +const CHOICES = ['rock', 'paper', 'scissors']; +const EMOJI = { rock: '๐Ÿชจ', paper: '๐Ÿ“„', scissors: 'โœ‚๏ธ' }; + +const OUTCOMES = { + rock: { rock: 'tie', paper: 'lose', scissors: 'win' }, + paper: { rock: 'win', paper: 'tie', scissors: 'lose' }, + scissors: { rock: 'lose', paper: 'win', scissors: 'tie' }, +}; + +export default { + data: new SlashCommandBuilder() + .setName('rps') + .setDescription('Play Rock Paper Scissors against the bot') + .addStringOption(opt => + opt.setName('choice') + .setDescription('Your choice') + .setRequired(true) + .addChoices( + { name: '๐Ÿชจ Rock', value: 'rock' }, + { name: '๐Ÿ“„ Paper', value: 'paper' }, + { name: 'โœ‚๏ธ Scissors', value: 'scissors' }, + ) + ), + + async execute(interaction) { + const userChoice = interaction.options.getString('choice'); + const botChoice = CHOICES[Math.floor(Math.random() * CHOICES.length)]; + const result = OUTCOMES[userChoice][botChoice]; + + const config = { + win: { title: '๐ŸŽ‰ You Win!', color: 'success' }, + lose: { title: '๐Ÿ˜” You Lose!', color: 'error' }, + tie: { title: "๐Ÿค It's a Tie!", color: 'warning' }, + }[result]; + + const embed = createEmbed({ + title: config.title, + color: config.color, + }).addFields( + { name: 'Your choice', value: `${EMOJI[userChoice]} ${userChoice}`, inline: true }, + { name: 'My choice', value: `${EMOJI[botChoice]} ${botChoice}`, inline: true }, + ); + + await InteractionHelper.safeReply(interaction, { embeds: [embed] }); + }, +}; diff --git a/src/commands/Fun/snipe.js b/src/commands/Fun/snipe.js new file mode 100644 index 0000000000..e00c35052d --- /dev/null +++ b/src/commands/Fun/snipe.js @@ -0,0 +1,37 @@ +import { SlashCommandBuilder } from 'discord.js'; +import { createEmbed, errorEmbed } from '../../utils/embeds.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { snipeCache } from '../../utils/snipeCache.js'; + +export default { + data: new SlashCommandBuilder() + .setName('snipe') + .setDescription('Show the last deleted message in this channel'), + + async execute(interaction) { + const cached = snipeCache.get(interaction.channelId); + + if (!cached) { + return InteractionHelper.safeReply(interaction, { + embeds: [errorEmbed('Nothing to Snipe', 'No recently deleted messages found in this channel.')], + ephemeral: true, + }); + } + + const embed = createEmbed({ + title: '๐Ÿ” Sniped Message', + description: cached.content || '*(no text content)*', + color: 'warning', + footer: { text: `Deleted โ€ข ${cached.author}` }, + timestamp: false, + }); + + embed.setTimestamp(cached.timestamp); + + if (cached.attachmentUrl) { + embed.setImage(cached.attachmentUrl); + } + + await InteractionHelper.safeReply(interaction, { embeds: [embed] }); + }, +}; diff --git a/src/config/changelog.js b/src/config/changelog.js index 2932f1652f..03b38b79ff 100644 --- a/src/config/changelog.js +++ b/src/config/changelog.js @@ -1,4 +1,14 @@ export const changelog = [ + { + 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', diff --git a/src/events/messageDelete.js b/src/events/messageDelete.js index cc737ba06c..8b8e66d93f 100644 --- a/src/events/messageDelete.js +++ b/src/events/messageDelete.js @@ -2,6 +2,7 @@ import { Events } from 'discord.js'; import { logEvent, EVENT_TYPES } from '../services/loggingService.js'; import { logger } from '../utils/logger.js'; import { getReactionRoleMessage, deleteReactionRoleMessage } from '../services/reactionRoleService.js'; +import { snipeCache } from '../utils/snipeCache.js'; const MAX_LOGGED_MESSAGE_CONTENT_LENGTH = 1024; @@ -56,6 +57,15 @@ export default { if (message.author?.bot) return; + if (message.content || message.attachments.size > 0) { + snipeCache.set(message.channel.id, { + content: message.content, + author: message.author ? `${message.author.tag} (${message.author.id})` : 'Unknown', + attachmentUrl: message.attachments.first()?.url ?? null, + timestamp: message.createdTimestamp, + }); + } + const fields = []; 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(); From e3ea2794abb3d6b15ac1d6261d379ff4438cbcf0 Mon Sep 17 00:00:00 2001 From: Itay100K Date: Mon, 4 May 2026 22:34:56 +0300 Subject: [PATCH 037/275] Update Fun category description in help menu Co-Authored-By: Claude Sonnet 4.6 --- src/commands/Core/help.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/Core/help.js b/src/commands/Core/help.js index d97ac5e978..5842bad192 100644 --- a/src/commands/Core/help.js +++ b/src/commands/Core/help.js @@ -22,7 +22,7 @@ const HELP_MENU_TIMEOUT_MS = 5 * 60 * 1000; const CATEGORY_INFO = { Core: { icon: "โ„น๏ธ", desc: "Core bot commands and information" }, Moderation: { icon: "๐Ÿ›ก๏ธ", desc: "Server moderation, user management, enforcement tools, and NSFW content detection" }, - Fun: { icon: "๐ŸŽฎ", desc: "Games, entertainment, and interactive commands" }, + Fun: { icon: "๐ŸŽฎ", desc: "8-ball, RPS, roast, snipe, memes, jokes, ship, fight, and more fun commands" }, Leveling: { icon: "๐Ÿ“Š", desc: "User levels, XP system, and progression tracking" }, Utility: { icon: "๐Ÿ”ง", desc: "Useful tools and server utilities" }, Ticket: { icon: "๐ŸŽซ", desc: "Support ticket system for server management" }, From 73aa22ce5669920548d51edd664e490bce49706f Mon Sep 17 00:00:00 2001 From: Itay100K Date: Mon, 4 May 2026 22:37:02 +0300 Subject: [PATCH 038/275] Fix /snipe not caching messages without text content Co-Authored-By: Claude Sonnet 4.6 --- src/events/messageDelete.js | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/events/messageDelete.js b/src/events/messageDelete.js index 8b8e66d93f..792eb640a4 100644 --- a/src/events/messageDelete.js +++ b/src/events/messageDelete.js @@ -57,14 +57,12 @@ export default { if (message.author?.bot) return; - if (message.content || message.attachments.size > 0) { - snipeCache.set(message.channel.id, { - content: message.content, - author: message.author ? `${message.author.tag} (${message.author.id})` : 'Unknown', - attachmentUrl: message.attachments.first()?.url ?? null, - timestamp: message.createdTimestamp, - }); - } + snipeCache.set(message.channel.id, { + content: message.content || null, + author: message.author ? `${message.author.tag} (${message.author.id})` : 'Unknown', + attachmentUrl: message.attachments?.first()?.url ?? null, + timestamp: message.createdTimestamp, + }); const fields = []; From 43ace27bd0b2628ca737b437a46d26ff1477a535 Mon Sep 17 00:00:00 2001 From: Itay100K Date: Mon, 4 May 2026 22:39:25 +0300 Subject: [PATCH 039/275] Fix /snipe using message.channelId instead of message.channel.id Co-Authored-By: Claude Sonnet 4.6 --- src/events/messageDelete.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/events/messageDelete.js b/src/events/messageDelete.js index 792eb640a4..7cd8779280 100644 --- a/src/events/messageDelete.js +++ b/src/events/messageDelete.js @@ -57,7 +57,7 @@ export default { if (message.author?.bot) return; - snipeCache.set(message.channel.id, { + snipeCache.set(message.channelId, { content: message.content || null, author: message.author ? `${message.author.tag} (${message.author.id})` : 'Unknown', attachmentUrl: message.attachments?.first()?.url ?? null, From 3a18eeb3d332fbda8d65b985d8399d8fd136474f Mon Sep 17 00:00:00 2001 From: Itay100K Date: Mon, 4 May 2026 22:41:38 +0300 Subject: [PATCH 040/275] Remove debug logging from /snipe Co-Authored-By: Claude Sonnet 4.6 --- src/commands/Fun/snipe.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/commands/Fun/snipe.js b/src/commands/Fun/snipe.js index e00c35052d..9af692bd82 100644 --- a/src/commands/Fun/snipe.js +++ b/src/commands/Fun/snipe.js @@ -2,7 +2,6 @@ import { SlashCommandBuilder } from 'discord.js'; import { createEmbed, errorEmbed } from '../../utils/embeds.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { snipeCache } from '../../utils/snipeCache.js'; - export default { data: new SlashCommandBuilder() .setName('snipe') From bfdb2ba9e4277e6366743e3b330d07efc29f72c8 Mon Sep 17 00:00:00 2001 From: Itay100K Date: Tue, 5 May 2026 08:33:25 +0300 Subject: [PATCH 041/275] Add music system with /play and /music subcommands Co-Authored-By: Claude Sonnet 4.6 --- Dockerfile | 2 + package-lock.json | 150 ++++++++++++++++++++++++++++++++++++ package.json | 5 +- src/commands/Core/help.js | 1 + src/commands/Music/music.js | 105 +++++++++++++++++++++++++ src/commands/Music/play.js | 77 ++++++++++++++++++ src/config/changelog.js | 9 +++ src/services/musicPlayer.js | 135 ++++++++++++++++++++++++++++++++ 8 files changed, 483 insertions(+), 1 deletion(-) create mode 100644 src/commands/Music/music.js create mode 100644 src/commands/Music/play.js create mode 100644 src/services/musicPlayer.js diff --git a/Dockerfile b/Dockerfile index 576813ed8f..afb7fc6598 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,8 @@ WORKDIR /usr/src/app COPY package*.json ./ # Install only production dependencies +RUN apk add --no-cache ffmpeg + RUN npm ci --omit=dev # Bundle app source diff --git a/package-lock.json b/package-lock.json index f3a8311dd0..ebdff0c472 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,12 +9,16 @@ "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", + "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" @@ -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,21 @@ "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==" }, + "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 +1092,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 +1196,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 +1249,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 +1789,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 +2338,19 @@ "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==" }, + "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 +2461,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 +2536,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 +2572,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..d63b712c95 100644 --- a/package.json +++ b/package.json @@ -16,17 +16,20 @@ }, "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", + "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/src/commands/Core/help.js b/src/commands/Core/help.js index 5842bad192..ce784c6a06 100644 --- a/src/commands/Core/help.js +++ b/src/commands/Core/help.js @@ -37,6 +37,7 @@ const CATEGORY_INFO = { Serverstats: { icon: "๐Ÿ“ˆ", desc: "Server statistics and display counters" }, Logging: { icon: "๐Ÿ“‹", desc: "Server event logging and audit trails" }, Voice: { icon: "๐Ÿ”Š", desc: "Voice channel commands and activities" }, + Music: { icon: "๐ŸŽต", desc: "Music playback โ€” play, pause, skip, and queue songs in voice channels" }, }; export async function createInitialHelpMenu(client, member) { diff --git a/src/commands/Music/music.js b/src/commands/Music/music.js new file mode 100644 index 0000000000..a55af7c24a --- /dev/null +++ b/src/commands/Music/music.js @@ -0,0 +1,105 @@ +import { SlashCommandBuilder } from 'discord.js'; +import { createEmbed, errorEmbed } from '../../utils/embeds.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { getPlayer } from '../../services/musicPlayer.js'; + +export default { + data: new SlashCommandBuilder() + .setName('music') + .setDescription('Control music playback') + .addSubcommand(s => s.setName('stop').setDescription('Stop playback and leave the voice channel')) + .addSubcommand(s => s.setName('skip').setDescription('Skip the current song')) + .addSubcommand(s => s.setName('queue').setDescription('Show the current music queue')) + .addSubcommand(s => s.setName('pause').setDescription('Pause the current song')) + .addSubcommand(s => s.setName('resume').setDescription('Resume the paused song')), + + category: 'Music', + + async execute(interaction) { + const sub = interaction.options.getSubcommand(); + const player = getPlayer(interaction.guildId); + + if (sub === 'queue') { + if (!player?.currentSong && !player?.songs.length) { + return InteractionHelper.safeReply(interaction, { + embeds: [errorEmbed('Empty Queue', 'Nothing is playing or queued right now.')], + ephemeral: true, + }); + } + const lines = []; + if (player.currentSong) { + lines.push(`**โ–ถ๏ธ Now Playing:** [${player.currentSong.title}](${player.currentSong.url}) \`${player.currentSong.durationFormatted}\``); + } + if (player.songs.length > 0) { + lines.push('\n**Up Next:**'); + player.songs.slice(0, 10).forEach((s, i) => { + lines.push(`${i + 1}. [${s.title}](${s.url}) \`${s.durationFormatted}\``); + }); + if (player.songs.length > 10) lines.push(`... and ${player.songs.length - 10} more`); + } + return InteractionHelper.safeReply(interaction, { + embeds: [createEmbed({ + title: '๐ŸŽต Music Queue', + description: lines.join('\n'), + color: 'primary', + footer: { text: `${player.songs.length} song(s) in queue` }, + })], + }); + } + + if (!player) { + return InteractionHelper.safeReply(interaction, { + embeds: [errorEmbed('Not Playing', 'The bot is not in a voice channel.')], + ephemeral: true, + }); + } + + if (sub === 'stop') { + player.songs = []; + player.destroy(); + return InteractionHelper.safeReply(interaction, { + embeds: [createEmbed({ title: 'โน๏ธ Stopped', description: 'Stopped playback and left the voice channel.', color: 'warning' })], + }); + } + + if (sub === 'skip') { + if (!player.currentSong) { + return InteractionHelper.safeReply(interaction, { + embeds: [errorEmbed('Not Playing', 'Nothing is playing right now.')], + ephemeral: true, + }); + } + const title = player.currentSong.title; + player.skip(); + return InteractionHelper.safeReply(interaction, { + embeds: [createEmbed({ title: 'โญ๏ธ Skipped', description: `Skipped **${title}**`, color: 'primary' })], + }); + } + + if (sub === 'pause') { + if (!player.isPlaying) { + return InteractionHelper.safeReply(interaction, { + embeds: [errorEmbed('Not Playing', 'Nothing is playing right now.')], + ephemeral: true, + }); + } + player.pause(); + return InteractionHelper.safeReply(interaction, { + embeds: [createEmbed({ title: 'โธ๏ธ Paused', description: `Paused **${player.currentSong?.title}**`, color: 'warning' })], + }); + } + + if (sub === 'resume') { + if (!player.isPaused) { + return InteractionHelper.safeReply(interaction, { + embeds: [errorEmbed('Not Paused', 'Nothing is paused right now.')], + ephemeral: true, + }); + } + player.resume(); + return InteractionHelper.safeReply(interaction, { + embeds: [createEmbed({ title: 'โ–ถ๏ธ Resumed', description: `Resumed **${player.currentSong?.title}**`, color: 'success' })], + }); + } + }, +}; diff --git a/src/commands/Music/play.js b/src/commands/Music/play.js new file mode 100644 index 0000000000..fd3d4782c0 --- /dev/null +++ b/src/commands/Music/play.js @@ -0,0 +1,77 @@ +import { SlashCommandBuilder, ChannelType } from 'discord.js'; +import { createEmbed, errorEmbed } from '../../utils/embeds.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { logger } from '../../utils/logger.js'; +import { handleInteractionError } from '../../utils/errorHandler.js'; +import { getOrCreatePlayer, searchSong } from '../../services/musicPlayer.js'; + +export default { + data: new SlashCommandBuilder() + .setName('play') + .setDescription('Play a song in a voice channel') + .addStringOption(o => o.setName('song').setDescription('Song name or YouTube URL').setRequired(true)) + .addChannelOption(o => + o.setName('channel') + .setDescription('Voice channel to join (defaults to your current VC)') + .addChannelTypes(ChannelType.GuildVoice) + ), + + category: 'Music', + + async execute(interaction) { + try { + await InteractionHelper.safeDefer(interaction); + + const query = interaction.options.getString('song'); + const channelOption = interaction.options.getChannel('channel'); + const voiceChannel = channelOption ?? interaction.member.voice.channel; + + if (!voiceChannel) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('No Voice Channel', 'Join a voice channel or specify one with the `channel` option.')], + }); + } + + const botPerms = voiceChannel.permissionsFor(interaction.guild.members.me); + if (!botPerms.has('Connect') || !botPerms.has('Speak')) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Missing Permissions', 'I need **Connect** and **Speak** permissions in that channel.')], + }); + } + + const song = await searchSong(query); + if (!song) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Not Found', `Could not find a song for: \`${query}\``)], + }); + } + + song.requestedBy = interaction.user.tag; + + const player = getOrCreatePlayer(interaction.guildId); + + if (!player.connection) { + await player.connect(voiceChannel); + } + + const started = await player.addSong(song); + + const embed = createEmbed({ + title: started ? 'โ–ถ๏ธ Now Playing' : 'โž• Added to Queue', + description: `**[${song.title}](${song.url})**`, + color: 'success', + }).addFields( + { name: 'โฑ๏ธ Duration', value: song.durationFormatted, inline: true }, + { name: '๐ŸŽ™๏ธ Channel', value: voiceChannel.name, inline: true }, + { name: '๐Ÿ‘ค Requested by', value: interaction.user.tag, inline: true }, + ); + if (song.thumbnail) embed.setThumbnail(song.thumbnail); + + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); + logger.info(`Music: ${interaction.user.tag} queued "${song.title}" in ${interaction.guild.name}`); + } catch (error) { + logger.error('Play command error:', error); + await handleInteractionError(interaction, error, { commandName: 'play' }); + } + }, +}; diff --git a/src/config/changelog.js b/src/config/changelog.js index 03b38b79ff..518e42668c 100644 --- a/src/config/changelog.js +++ b/src/config/changelog.js @@ -9,6 +9,15 @@ export const changelog = [ { type: 'new', text: 'Added `/snipe` โ€” reveal the last deleted message in the current channel' }, ], }, + { + version: '2.6.0', + date: '2026-05-05', + entries: [ + { type: 'new', text: 'Added music system โ€” `/play`, `/stop`, `/skip`, `/queue`, `/pause`, `/resume`' }, + { type: 'new', text: '`/play` lets you pick any voice channel to join, or defaults to your current VC. Supports song names and YouTube URLs.' }, + { type: 'new', text: 'Queue system โ€” songs stack up and play in order. Bot auto-disconnects after 2 minutes of inactivity.' }, + ], + }, { version: '2.5.3', date: '2026-05-04', diff --git a/src/services/musicPlayer.js b/src/services/musicPlayer.js new file mode 100644 index 0000000000..082947af68 --- /dev/null +++ b/src/services/musicPlayer.js @@ -0,0 +1,135 @@ +import { + joinVoiceChannel, + createAudioPlayer, + createAudioResource, + AudioPlayerStatus, + VoiceConnectionStatus, + entersState, +} from '@discordjs/voice'; +import play from 'play-dl'; + +const queues = new Map(); + +function formatDuration(seconds) { + if (!seconds) return '??:??'; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = seconds % 60; + if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; + return `${m}:${String(s).padStart(2, '0')}`; +} + +export async function searchSong(query) { + const isUrl = play.yt_validate(query) === 'video'; + if (isUrl) { + const info = await play.video_info(query); + const v = info.video_details; + return { + title: v.title, + url: v.url, + duration: v.durationInSec, + durationFormatted: formatDuration(v.durationInSec), + thumbnail: v.thumbnails?.[0]?.url, + }; + } + const results = await play.search(query, { limit: 1, source: { youtube: 'video' } }); + if (!results.length) return null; + const v = results[0]; + return { + title: v.title, + url: v.url, + duration: v.durationInSec, + durationFormatted: formatDuration(v.durationInSec), + thumbnail: v.thumbnails?.[0]?.url, + }; +} + +class GuildMusicPlayer { + constructor(guildId) { + this.guildId = guildId; + this.songs = []; + this.currentSong = null; + this.connection = null; + this._idleTimer = null; + this.audioPlayer = createAudioPlayer(); + + this.audioPlayer.on(AudioPlayerStatus.Idle, () => { + if (this.songs.length > 0) { + this._playNext(); + } else { + this.currentSong = null; + this._idleTimer = setTimeout(() => this.destroy(), 120_000); + } + }); + + this.audioPlayer.on('error', () => this._playNext()); + } + + async connect(voiceChannel) { + this.connection = joinVoiceChannel({ + channelId: voiceChannel.id, + guildId: voiceChannel.guild.id, + adapterCreator: voiceChannel.guild.voiceAdapterCreator, + }); + this.connection.subscribe(this.audioPlayer); + await entersState(this.connection, VoiceConnectionStatus.Ready, 30_000); + + this.connection.on(VoiceConnectionStatus.Disconnected, async () => { + try { + await Promise.race([ + entersState(this.connection, VoiceConnectionStatus.Signalling, 5_000), + entersState(this.connection, VoiceConnectionStatus.Connecting, 5_000), + ]); + } catch { + this.destroy(); + } + }); + } + + async _playNext() { + if (!this.songs.length) { + this.currentSong = null; + return; + } + this.currentSong = this.songs.shift(); + try { + const stream = await play.stream(this.currentSong.url); + const resource = createAudioResource(stream.stream, { inputType: stream.type }); + this.audioPlayer.play(resource); + } catch { + this._playNext(); + } + } + + async addSong(song) { + if (this._idleTimer) { + clearTimeout(this._idleTimer); + this._idleTimer = null; + } + this.songs.push(song); + const idle = this.audioPlayer.state.status === AudioPlayerStatus.Idle; + if (idle) { + await this._playNext(); + return true; + } + return false; + } + + skip() { this.audioPlayer.stop(); } + pause() { this.audioPlayer.pause(); } + resume() { this.audioPlayer.unpause(); } + + destroy() { + try { this.connection?.destroy(); } catch {} + queues.delete(this.guildId); + } + + get isPlaying() { return this.audioPlayer.state.status === AudioPlayerStatus.Playing; } + get isPaused() { return this.audioPlayer.state.status === AudioPlayerStatus.Paused; } +} + +export function getPlayer(guildId) { return queues.get(guildId); } +export function getOrCreatePlayer(guildId) { + if (!queues.has(guildId)) queues.set(guildId, new GuildMusicPlayer(guildId)); + return queues.get(guildId); +} From f9ba097e0fd171b17d2c3160767cf5254bc0b487 Mon Sep 17 00:00:00 2001 From: Itay100K Date: Tue, 5 May 2026 08:40:24 +0300 Subject: [PATCH 042/275] Fix unhandled AbortError in voice disconnect handler Co-Authored-By: Claude Sonnet 4.6 --- src/services/musicPlayer.js | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/services/musicPlayer.js b/src/services/musicPlayer.js index 082947af68..dc7c709b35 100644 --- a/src/services/musicPlayer.js +++ b/src/services/musicPlayer.js @@ -72,17 +72,20 @@ class GuildMusicPlayer { adapterCreator: voiceChannel.guild.voiceAdapterCreator, }); this.connection.subscribe(this.audioPlayer); - await entersState(this.connection, VoiceConnectionStatus.Ready, 30_000); + + try { + await entersState(this.connection, VoiceConnectionStatus.Ready, 30_000); + } catch (err) { + this.destroy(); + throw err; + } this.connection.on(VoiceConnectionStatus.Disconnected, async () => { - try { - await Promise.race([ - entersState(this.connection, VoiceConnectionStatus.Signalling, 5_000), - entersState(this.connection, VoiceConnectionStatus.Connecting, 5_000), - ]); - } catch { - this.destroy(); - } + // Silence both rejections before racing โ€” Promise.race leaves the loser unhandled + const p1 = entersState(this.connection, VoiceConnectionStatus.Signalling, 5_000).catch(() => null); + const p2 = entersState(this.connection, VoiceConnectionStatus.Connecting, 5_000).catch(() => null); + const result = await Promise.race([p1, p2]); + if (result === null) this.destroy(); }); } From 0af468aaec1796c4f26998f6c48c1e76ef53b388 Mon Sep 17 00:00:00 2001 From: Itay100K Date: Tue, 5 May 2026 08:50:53 +0300 Subject: [PATCH 043/275] Add rich music player embed with pause/skip/stop/shuffle buttons Co-Authored-By: Claude Sonnet 4.6 --- src/commands/Music/play.js | 40 ++++++++----- src/interactions/buttons/music.js | 95 +++++++++++++++++++++++++++++++ src/services/musicPlayer.js | 77 ++++++++++++++++++++++++- 3 files changed, 194 insertions(+), 18 deletions(-) create mode 100644 src/interactions/buttons/music.js diff --git a/src/commands/Music/play.js b/src/commands/Music/play.js index fd3d4782c0..74c31a0c3f 100644 --- a/src/commands/Music/play.js +++ b/src/commands/Music/play.js @@ -1,9 +1,9 @@ -import { SlashCommandBuilder, ChannelType } from 'discord.js'; -import { createEmbed, errorEmbed } from '../../utils/embeds.js'; +import { SlashCommandBuilder, ChannelType, EmbedBuilder } from 'discord.js'; +import { errorEmbed } from '../../utils/embeds.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { logger } from '../../utils/logger.js'; import { handleInteractionError } from '../../utils/errorHandler.js'; -import { getOrCreatePlayer, searchSong } from '../../services/musicPlayer.js'; +import { getOrCreatePlayer, searchSong, buildNowPlayingEmbed, buildPlayerRow } from '../../services/musicPlayer.js'; export default { data: new SlashCommandBuilder() @@ -46,7 +46,7 @@ export default { }); } - song.requestedBy = interaction.user.tag; + song.requestedBy = interaction.user.username; const player = getOrCreatePlayer(interaction.guildId); @@ -56,18 +56,28 @@ export default { const started = await player.addSong(song); - const embed = createEmbed({ - title: started ? 'โ–ถ๏ธ Now Playing' : 'โž• Added to Queue', - description: `**[${song.title}](${song.url})**`, - color: 'success', - }).addFields( - { name: 'โฑ๏ธ Duration', value: song.durationFormatted, inline: true }, - { name: '๐ŸŽ™๏ธ Channel', value: voiceChannel.name, inline: true }, - { name: '๐Ÿ‘ค Requested by', value: interaction.user.tag, inline: true }, - ); - if (song.thumbnail) embed.setThumbnail(song.thumbnail); + if (started) { + // Show the full player embed with controls + const msg = await InteractionHelper.safeEditReply(interaction, { + embeds: [buildNowPlayingEmbed(song)], + components: [buildPlayerRow(false)], + }); + player.playerMessage = msg; + } else { + // Song was queued โ€” show a smaller confirmation + const queued = new EmbedBuilder() + .setColor(0x5865F2) + .setTitle('โž• Added to Queue') + .setDescription(`**[${song.title}](${song.url})**`) + .addFields( + { name: 'Duration', value: song.durationFormatted, inline: true }, + { name: 'Position', value: `#${player.songs.length}`, inline: true }, + ) + .setFooter({ text: `Requested by ${song.requestedBy}` }); + if (song.thumbnail) queued.setThumbnail(song.thumbnail); + await InteractionHelper.safeEditReply(interaction, { embeds: [queued] }); + } - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); logger.info(`Music: ${interaction.user.tag} queued "${song.title}" in ${interaction.guild.name}`); } catch (error) { logger.error('Play command error:', error); diff --git a/src/interactions/buttons/music.js b/src/interactions/buttons/music.js new file mode 100644 index 0000000000..acce848661 --- /dev/null +++ b/src/interactions/buttons/music.js @@ -0,0 +1,95 @@ +import { EmbedBuilder } from 'discord.js'; +import { getPlayer, buildNowPlayingEmbed, buildPlayerRow } from '../../services/musicPlayer.js'; +import { errorEmbed } from '../../utils/embeds.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; + +function noPlayerReply(interaction) { + return InteractionHelper.safeReply(interaction, { + embeds: [errorEmbed('Not Playing', 'Nothing is playing right now.')], + ephemeral: true, + }); +} + +export default [ + { + name: 'music-pause', + async execute(interaction) { + const player = getPlayer(interaction.guildId); + if (!player?.currentSong) return noPlayerReply(interaction); + + if (player.isPlaying) { + player.pause(); + } else if (player.isPaused) { + player.resume(); + } + + const isPaused = player.isPaused; + await interaction.update({ + embeds: [buildNowPlayingEmbed(player.currentSong, isPaused)], + components: [buildPlayerRow(isPaused)], + }); + }, + }, + { + name: 'music-skip', + async execute(interaction) { + const player = getPlayer(interaction.guildId); + if (!player?.currentSong) return noPlayerReply(interaction); + + const skipped = player.currentSong.title; + player.skip(); + + await interaction.update({ + embeds: [new EmbedBuilder() + .setColor(0x5C5C5C) + .setDescription(`โญ๏ธ Skipped **${skipped}**`) + .setTimestamp()], + components: [], + }); + }, + }, + { + name: 'music-stop', + async execute(interaction) { + const player = getPlayer(interaction.guildId); + if (!player) return noPlayerReply(interaction); + + player.songs = []; + player.playerMessage = null; + player.destroy(); + + await interaction.update({ + embeds: [new EmbedBuilder() + .setColor(0x5C5C5C) + .setTitle('โน๏ธ Stopped') + .setDescription('Stopped playback and left the voice channel.') + .setTimestamp()], + components: [], + }); + }, + }, + { + name: 'music-shuffle', + async execute(interaction) { + const player = getPlayer(interaction.guildId); + if (!player?.currentSong) return noPlayerReply(interaction); + + if (player.songs.length < 2) { + return InteractionHelper.safeReply(interaction, { + embeds: [errorEmbed('Nothing to Shuffle', 'Need at least 2 songs in the queue to shuffle.')], + ephemeral: true, + }); + } + + player.shuffle(); + + await interaction.reply({ + embeds: [new EmbedBuilder() + .setColor(0x5865F2) + .setDescription(`๐Ÿ”€ Queue shuffled! (${player.songs.length} songs)`) + .setTimestamp()], + ephemeral: true, + }); + }, + }, +]; diff --git a/src/services/musicPlayer.js b/src/services/musicPlayer.js index dc7c709b35..2187e443fb 100644 --- a/src/services/musicPlayer.js +++ b/src/services/musicPlayer.js @@ -6,6 +6,7 @@ import { VoiceConnectionStatus, entersState, } from '@discordjs/voice'; +import { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; import play from 'play-dl'; const queues = new Map(); @@ -19,6 +20,46 @@ function formatDuration(seconds) { return `${m}:${String(s).padStart(2, '0')}`; } +export function buildNowPlayingEmbed(song, isPaused = false) { + const embed = new EmbedBuilder() + .setColor(0xE5000E) + .setTitle(isPaused ? 'โธ๏ธ Paused' : '๐ŸŽต Now Playing') + .setDescription(`**[${song.title}](${song.url})**`) + .addFields( + { name: 'Duration', value: song.durationFormatted, inline: true }, + { name: 'Source', value: `[YouTube](${song.url})`, inline: true }, + ) + .setFooter({ text: `Requested by ${song.requestedBy ?? 'Unknown'}` }) + .setTimestamp(); + if (song.thumbnail) embed.setThumbnail(song.thumbnail); + return embed; +} + +export function buildPlayerRow(isPaused = false) { + return new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('music-pause') + .setEmoji(isPaused ? 'โ–ถ๏ธ' : 'โธ๏ธ') + .setLabel(isPaused ? 'Resume' : 'Pause') + .setStyle(isPaused ? ButtonStyle.Success : ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId('music-skip') + .setEmoji('โญ๏ธ') + .setLabel('Skip') + .setStyle(ButtonStyle.Secondary), + new ButtonBuilder() + .setCustomId('music-stop') + .setEmoji('โน๏ธ') + .setLabel('Stop') + .setStyle(ButtonStyle.Danger), + new ButtonBuilder() + .setCustomId('music-shuffle') + .setEmoji('๐Ÿ”€') + .setLabel('Shuffle') + .setStyle(ButtonStyle.Secondary), + ); +} + export async function searchSong(query) { const isUrl = play.yt_validate(query) === 'video'; if (isUrl) { @@ -50,6 +91,7 @@ class GuildMusicPlayer { this.songs = []; this.currentSong = null; this.connection = null; + this.playerMessage = null; this._idleTimer = null; this.audioPlayer = createAudioPlayer(); @@ -58,6 +100,7 @@ class GuildMusicPlayer { this._playNext(); } else { this.currentSong = null; + this._updateMessageQueueEnded(); this._idleTimer = setTimeout(() => this.destroy(), 120_000); } }); @@ -70,6 +113,7 @@ class GuildMusicPlayer { channelId: voiceChannel.id, guildId: voiceChannel.guild.id, adapterCreator: voiceChannel.guild.voiceAdapterCreator, + selfDeaf: true, }); this.connection.subscribe(this.audioPlayer); @@ -81,7 +125,6 @@ class GuildMusicPlayer { } this.connection.on(VoiceConnectionStatus.Disconnected, async () => { - // Silence both rejections before racing โ€” Promise.race leaves the loser unhandled const p1 = entersState(this.connection, VoiceConnectionStatus.Signalling, 5_000).catch(() => null); const p2 = entersState(this.connection, VoiceConnectionStatus.Connecting, 5_000).catch(() => null); const result = await Promise.race([p1, p2]); @@ -99,6 +142,7 @@ class GuildMusicPlayer { const stream = await play.stream(this.currentSong.url); const resource = createAudioResource(stream.stream, { inputType: stream.type }); this.audioPlayer.play(resource); + this._updateMessage(); } catch { this._playNext(); } @@ -118,12 +162,39 @@ class GuildMusicPlayer { return false; } + shuffle() { + for (let i = this.songs.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [this.songs[i], this.songs[j]] = [this.songs[j], this.songs[i]]; + } + } + skip() { this.audioPlayer.stop(); } pause() { this.audioPlayer.pause(); } resume() { this.audioPlayer.unpause(); } + _updateMessage(isPaused = false) { + if (!this.playerMessage || !this.currentSong) return; + this.playerMessage.edit({ + embeds: [buildNowPlayingEmbed(this.currentSong, isPaused)], + components: [buildPlayerRow(isPaused)], + }).catch(() => {}); + } + + _updateMessageQueueEnded() { + if (!this.playerMessage) return; + const ended = new EmbedBuilder() + .setColor(0x5C5C5C) + .setTitle('โน๏ธ Queue Ended') + .setDescription('No more songs in the queue.') + .setTimestamp(); + this.playerMessage.edit({ embeds: [ended], components: [] }).catch(() => {}); + this.playerMessage = null; + } + destroy() { try { this.connection?.destroy(); } catch {} + if (this._idleTimer) clearTimeout(this._idleTimer); queues.delete(this.guildId); } @@ -131,8 +202,8 @@ class GuildMusicPlayer { get isPaused() { return this.audioPlayer.state.status === AudioPlayerStatus.Paused; } } -export function getPlayer(guildId) { return queues.get(guildId); } -export function getOrCreatePlayer(guildId) { +export function getPlayer(guildId) { return queues.get(guildId); } +export function getOrCreatePlayer(guildId) { if (!queues.has(guildId)) queues.set(guildId, new GuildMusicPlayer(guildId)); return queues.get(guildId); } From 6f7c7f400425ab54fff6b84ed3415b7313d60b05 Mon Sep 17 00:00:00 2001 From: Itay100K Date: Tue, 5 May 2026 08:56:21 +0300 Subject: [PATCH 044/275] =?UTF-8?q?Fix=20voice=20connection=20blocking=20?= =?UTF-8?q?=E2=80=94=20remove=20entersState=20timeout=20that=20caused=20Un?= =?UTF-8?q?expected=20Error?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/commands/Music/play.js | 2 +- src/services/musicPlayer.js | 19 +++---------------- 2 files changed, 4 insertions(+), 17 deletions(-) diff --git a/src/commands/Music/play.js b/src/commands/Music/play.js index 74c31a0c3f..ef9d2c03f7 100644 --- a/src/commands/Music/play.js +++ b/src/commands/Music/play.js @@ -51,7 +51,7 @@ export default { const player = getOrCreatePlayer(interaction.guildId); if (!player.connection) { - await player.connect(voiceChannel); + player.connect(voiceChannel); } const started = await player.addSong(song); diff --git a/src/services/musicPlayer.js b/src/services/musicPlayer.js index 2187e443fb..861adcc39f 100644 --- a/src/services/musicPlayer.js +++ b/src/services/musicPlayer.js @@ -4,7 +4,6 @@ import { createAudioResource, AudioPlayerStatus, VoiceConnectionStatus, - entersState, } from '@discordjs/voice'; import { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; import play from 'play-dl'; @@ -108,7 +107,7 @@ class GuildMusicPlayer { this.audioPlayer.on('error', () => this._playNext()); } - async connect(voiceChannel) { + connect(voiceChannel) { this.connection = joinVoiceChannel({ channelId: voiceChannel.id, guildId: voiceChannel.guild.id, @@ -116,20 +115,8 @@ class GuildMusicPlayer { selfDeaf: true, }); this.connection.subscribe(this.audioPlayer); - - try { - await entersState(this.connection, VoiceConnectionStatus.Ready, 30_000); - } catch (err) { - this.destroy(); - throw err; - } - - this.connection.on(VoiceConnectionStatus.Disconnected, async () => { - const p1 = entersState(this.connection, VoiceConnectionStatus.Signalling, 5_000).catch(() => null); - const p2 = entersState(this.connection, VoiceConnectionStatus.Connecting, 5_000).catch(() => null); - const result = await Promise.race([p1, p2]); - if (result === null) this.destroy(); - }); + this.connection.on(VoiceConnectionStatus.Disconnected, () => this.destroy()); + this.connection.on(VoiceConnectionStatus.Destroyed, () => queues.delete(this.guildId)); } async _playNext() { From 7170510e1fa11d99a0eb651c3dfb5ce6e5889f03 Mon Sep 17 00:00:00 2001 From: Itay100K Date: Tue, 5 May 2026 09:04:17 +0300 Subject: [PATCH 045/275] Replace play-dl with yt-dlp for reliable YouTube streaming play-dl fails with Invalid URL on YouTube streams due to API changes. yt-dlp is actively maintained and handles YouTube bot detection properly. Co-Authored-By: Claude Sonnet 4.6 --- Dockerfile | 3 +- src/services/musicPlayer.js | 99 +++++++++++++++++++++++++------------ 2 files changed, 70 insertions(+), 32 deletions(-) diff --git a/Dockerfile b/Dockerfile index afb7fc6598..a8572f166b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,8 @@ WORKDIR /usr/src/app COPY package*.json ./ # Install only production dependencies -RUN apk add --no-cache ffmpeg +RUN apk add --no-cache ffmpeg python3 py3-pip && \ + pip3 install yt-dlp --break-system-packages --quiet RUN npm ci --omit=dev diff --git a/src/services/musicPlayer.js b/src/services/musicPlayer.js index 861adcc39f..20138a9493 100644 --- a/src/services/musicPlayer.js +++ b/src/services/musicPlayer.js @@ -4,9 +4,11 @@ import { createAudioResource, AudioPlayerStatus, VoiceConnectionStatus, + StreamType, } from '@discordjs/voice'; import { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; -import play from 'play-dl'; +import { spawn } from 'child_process'; +import { logger } from '../utils/logger.js'; const queues = new Map(); @@ -59,29 +61,52 @@ export function buildPlayerRow(isPaused = false) { ); } -export async function searchSong(query) { - const isUrl = play.yt_validate(query) === 'video'; - if (isUrl) { - const info = await play.video_info(query); - const v = info.video_details; - return { - title: v.title, - url: v.url, - duration: v.durationInSec, - durationFormatted: formatDuration(v.durationInSec), - thumbnail: v.thumbnails?.[0]?.url, - }; - } - const results = await play.search(query, { limit: 1, source: { youtube: 'video' } }); - if (!results.length) return null; - const v = results[0]; - return { - title: v.title, - url: v.url, - duration: v.durationInSec, - durationFormatted: formatDuration(v.durationInSec), - thumbnail: v.thumbnails?.[0]?.url, - }; +export function searchSong(query) { + const isUrl = /^https?:\/\//i.test(query); + const searchArg = isUrl ? query : `ytsearch1:${query}`; + + return new Promise((resolve) => { + const proc = spawn('yt-dlp', [ + '--no-playlist', '--quiet', '--no-warnings', + '-j', searchArg, + ]); + + let out = ''; + proc.stdout.on('data', chunk => { out += chunk; }); + proc.stderr.on('data', chunk => logger.warn('yt-dlp search stderr:', chunk.toString().trim())); + proc.on('close', code => { + if (code !== 0 || !out.trim()) return resolve(null); + try { + const info = JSON.parse(out.trim()); + resolve({ + title: info.title ?? 'Unknown', + url: info.webpage_url ?? `https://www.youtube.com/watch?v=${info.id}`, + duration: info.duration ?? 0, + durationFormatted: formatDuration(info.duration ?? 0), + thumbnail: info.thumbnail ?? null, + }); + } catch { + resolve(null); + } + }); + proc.on('error', err => { + logger.error('yt-dlp search error:', err); + resolve(null); + }); + }); +} + +function createYtdlpStream(url) { + const proc = spawn('yt-dlp', [ + '-f', 'bestaudio[ext=webm]/bestaudio[ext=m4a]/bestaudio', + '--no-playlist', '--quiet', '--no-warnings', + '-o', '-', url, + ], { stdio: ['ignore', 'pipe', 'pipe'] }); + + proc.stderr.on('data', chunk => logger.warn('yt-dlp stream stderr:', chunk.toString().trim())); + proc.on('error', err => logger.error('yt-dlp spawn error:', err)); + + return proc.stdout; } class GuildMusicPlayer { @@ -104,7 +129,10 @@ class GuildMusicPlayer { } }); - this.audioPlayer.on('error', () => this._playNext()); + this.audioPlayer.on('error', (err) => { + logger.error(`AudioPlayer error in guild ${guildId}:`, err.message); + this._playNext(); + }); } connect(voiceChannel) { @@ -115,22 +143,31 @@ class GuildMusicPlayer { selfDeaf: true, }); this.connection.subscribe(this.audioPlayer); - this.connection.on(VoiceConnectionStatus.Disconnected, () => this.destroy()); + this.connection.on(VoiceConnectionStatus.Ready, () => + logger.info(`Voice ready in guild ${this.guildId}`) + ); + this.connection.on(VoiceConnectionStatus.Disconnected, () => { + logger.warn(`Voice disconnected in guild ${this.guildId}`); + this.destroy(); + }); this.connection.on(VoiceConnectionStatus.Destroyed, () => queues.delete(this.guildId)); } - async _playNext() { + _playNext() { if (!this.songs.length) { this.currentSong = null; return; } this.currentSong = this.songs.shift(); + logger.info(`Streaming: "${this.currentSong.title}"`); + try { - const stream = await play.stream(this.currentSong.url); - const resource = createAudioResource(stream.stream, { inputType: stream.type }); + const stream = createYtdlpStream(this.currentSong.url); + const resource = createAudioResource(stream, { inputType: StreamType.Arbitrary }); this.audioPlayer.play(resource); this._updateMessage(); - } catch { + } catch (err) { + logger.error(`Stream error for "${this.currentSong.title}":`, err); this._playNext(); } } @@ -143,7 +180,7 @@ class GuildMusicPlayer { this.songs.push(song); const idle = this.audioPlayer.state.status === AudioPlayerStatus.Idle; if (idle) { - await this._playNext(); + this._playNext(); return true; } return false; From 0ac773477210e1e166286ad5a975806f383197a5 Mon Sep 17 00:00:00 2001 From: Itay100K Date: Tue, 5 May 2026 09:08:49 +0300 Subject: [PATCH 046/275] Fix voice connection: wait for UDP ready before playing, add yt-dlp node runtime Co-Authored-By: Claude Sonnet 4.6 --- src/commands/Music/play.js | 8 +++++++- src/services/musicPlayer.js | 23 ++++++++++++++++++++--- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/commands/Music/play.js b/src/commands/Music/play.js index ef9d2c03f7..9b32cd3704 100644 --- a/src/commands/Music/play.js +++ b/src/commands/Music/play.js @@ -51,7 +51,13 @@ export default { const player = getOrCreatePlayer(interaction.guildId); if (!player.connection) { - player.connect(voiceChannel); + try { + await player.connect(voiceChannel); + } catch { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Connection Failed', `Could not connect to **${voiceChannel.name}**. Check my permissions and try again.`)], + }); + } } const started = await player.addSong(song); diff --git a/src/services/musicPlayer.js b/src/services/musicPlayer.js index 20138a9493..cbcb54668e 100644 --- a/src/services/musicPlayer.js +++ b/src/services/musicPlayer.js @@ -68,6 +68,7 @@ export function searchSong(query) { return new Promise((resolve) => { const proc = spawn('yt-dlp', [ '--no-playlist', '--quiet', '--no-warnings', + '--js-runtimes', 'node', '-j', searchArg, ]); @@ -100,6 +101,7 @@ function createYtdlpStream(url) { const proc = spawn('yt-dlp', [ '-f', 'bestaudio[ext=webm]/bestaudio[ext=m4a]/bestaudio', '--no-playlist', '--quiet', '--no-warnings', + '--js-runtimes', 'node', '-o', '-', url, ], { stdio: ['ignore', 'pipe', 'pipe'] }); @@ -143,14 +145,29 @@ class GuildMusicPlayer { selfDeaf: true, }); this.connection.subscribe(this.audioPlayer); - this.connection.on(VoiceConnectionStatus.Ready, () => - logger.info(`Voice ready in guild ${this.guildId}`) - ); this.connection.on(VoiceConnectionStatus.Disconnected, () => { logger.warn(`Voice disconnected in guild ${this.guildId}`); this.destroy(); }); this.connection.on(VoiceConnectionStatus.Destroyed, () => queues.delete(this.guildId)); + + // Wait for the UDP connection to be ready before resolving + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error('Voice connection timed out')); + }, 20_000); + + this.connection.once(VoiceConnectionStatus.Ready, () => { + clearTimeout(timeout); + logger.info(`Voice ready in guild ${this.guildId}`); + resolve(); + }); + + this.connection.once(VoiceConnectionStatus.Destroyed, () => { + clearTimeout(timeout); + reject(new Error('Voice connection destroyed')); + }); + }); } _playNext() { From b36f9c807be794a79b1bd12a326bbbab08e1544c Mon Sep 17 00:00:00 2001 From: Itay100K Date: Tue, 5 May 2026 09:52:33 +0300 Subject: [PATCH 047/275] Replace music voice player with YouTube music finder /music now searches YouTube and returns up to 5 results with clickable links, duration, and channel info. Removes the voice player system which had unresolvable UDP connectivity issues in Docker. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/Core/help.js | 2 +- src/commands/Music/music.js | 155 ++++++++---------- src/commands/Music/play.js | 93 ----------- src/config/changelog.js | 5 +- src/interactions/buttons/music.js | 95 ------------ src/services/musicPlayer.js | 250 ------------------------------ 6 files changed, 71 insertions(+), 529 deletions(-) delete mode 100644 src/commands/Music/play.js delete mode 100644 src/interactions/buttons/music.js delete mode 100644 src/services/musicPlayer.js diff --git a/src/commands/Core/help.js b/src/commands/Core/help.js index ce784c6a06..e18784458a 100644 --- a/src/commands/Core/help.js +++ b/src/commands/Core/help.js @@ -37,7 +37,7 @@ const CATEGORY_INFO = { Serverstats: { icon: "๐Ÿ“ˆ", desc: "Server statistics and display counters" }, Logging: { icon: "๐Ÿ“‹", desc: "Server event logging and audit trails" }, Voice: { icon: "๐Ÿ”Š", desc: "Voice channel commands and activities" }, - Music: { icon: "๐ŸŽต", desc: "Music playback โ€” play, pause, skip, and queue songs in voice channels" }, + Music: { icon: "๐ŸŽต", desc: "Search for songs and get YouTube links" }, }; export async function createInitialHelpMenu(client, member) { diff --git a/src/commands/Music/music.js b/src/commands/Music/music.js index a55af7c24a..7c7412774a 100644 --- a/src/commands/Music/music.js +++ b/src/commands/Music/music.js @@ -1,105 +1,86 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed } from '../../utils/embeds.js'; +import { SlashCommandBuilder, EmbedBuilder } from 'discord.js'; +import { spawn } from 'child_process'; +import { errorEmbed } from '../../utils/embeds.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { getPlayer } from '../../services/musicPlayer.js'; +import { handleInteractionError } from '../../utils/errorHandler.js'; + +function searchYouTube(query) { + const isUrl = /^https?:\/\//i.test(query); + const searchArg = isUrl ? query : `ytsearch5:${query}`; + + return new Promise((resolve) => { + const proc = spawn('yt-dlp', [ + '--no-playlist', '--quiet', '--no-warnings', + '--flat-playlist', + '-j', searchArg, + ]); + + let out = ''; + proc.stdout.on('data', chunk => { out += chunk; }); + proc.on('close', code => { + if (code !== 0 || !out.trim()) return resolve([]); + try { + const results = out.trim().split('\n').map(line => { + const info = JSON.parse(line); + const id = info.id ?? info.webpage_url?.split('v=')[1]; + const duration = info.duration + ? `${Math.floor(info.duration / 60)}:${String(Math.floor(info.duration % 60)).padStart(2, '0')}` + : '??:??'; + return { + title: info.title ?? 'Unknown', + url: info.webpage_url ?? `https://www.youtube.com/watch?v=${id}`, + duration, + channel: info.channel ?? info.uploader ?? 'Unknown', + }; + }).filter(r => r.url); + resolve(results); + } catch { + resolve([]); + } + }); + proc.on('error', () => resolve([])); + }); +} export default { data: new SlashCommandBuilder() .setName('music') - .setDescription('Control music playback') - .addSubcommand(s => s.setName('stop').setDescription('Stop playback and leave the voice channel')) - .addSubcommand(s => s.setName('skip').setDescription('Skip the current song')) - .addSubcommand(s => s.setName('queue').setDescription('Show the current music queue')) - .addSubcommand(s => s.setName('pause').setDescription('Pause the current song')) - .addSubcommand(s => s.setName('resume').setDescription('Resume the paused song')), + .setDescription('Find music on YouTube') + .addStringOption(o => + o.setName('song') + .setDescription('Song name or YouTube URL to search for') + .setRequired(true) + ), category: 'Music', async execute(interaction) { - const sub = interaction.options.getSubcommand(); - const player = getPlayer(interaction.guildId); + try { + await InteractionHelper.safeDefer(interaction); - if (sub === 'queue') { - if (!player?.currentSong && !player?.songs.length) { - return InteractionHelper.safeReply(interaction, { - embeds: [errorEmbed('Empty Queue', 'Nothing is playing or queued right now.')], - ephemeral: true, - }); - } - const lines = []; - if (player.currentSong) { - lines.push(`**โ–ถ๏ธ Now Playing:** [${player.currentSong.title}](${player.currentSong.url}) \`${player.currentSong.durationFormatted}\``); - } - if (player.songs.length > 0) { - lines.push('\n**Up Next:**'); - player.songs.slice(0, 10).forEach((s, i) => { - lines.push(`${i + 1}. [${s.title}](${s.url}) \`${s.durationFormatted}\``); - }); - if (player.songs.length > 10) lines.push(`... and ${player.songs.length - 10} more`); - } - return InteractionHelper.safeReply(interaction, { - embeds: [createEmbed({ - title: '๐ŸŽต Music Queue', - description: lines.join('\n'), - color: 'primary', - footer: { text: `${player.songs.length} song(s) in queue` }, - })], - }); - } + const query = interaction.options.getString('song'); + const results = await searchYouTube(query); - if (!player) { - return InteractionHelper.safeReply(interaction, { - embeds: [errorEmbed('Not Playing', 'The bot is not in a voice channel.')], - ephemeral: true, - }); - } - - if (sub === 'stop') { - player.songs = []; - player.destroy(); - return InteractionHelper.safeReply(interaction, { - embeds: [createEmbed({ title: 'โน๏ธ Stopped', description: 'Stopped playback and left the voice channel.', color: 'warning' })], - }); - } - - if (sub === 'skip') { - if (!player.currentSong) { - return InteractionHelper.safeReply(interaction, { - embeds: [errorEmbed('Not Playing', 'Nothing is playing right now.')], - ephemeral: true, + if (!results.length) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Not Found', `No results found for: \`${query}\``)], }); } - const title = player.currentSong.title; - player.skip(); - return InteractionHelper.safeReply(interaction, { - embeds: [createEmbed({ title: 'โญ๏ธ Skipped', description: `Skipped **${title}**`, color: 'primary' })], - }); - } - if (sub === 'pause') { - if (!player.isPlaying) { - return InteractionHelper.safeReply(interaction, { - embeds: [errorEmbed('Not Playing', 'Nothing is playing right now.')], - ephemeral: true, - }); - } - player.pause(); - return InteractionHelper.safeReply(interaction, { - embeds: [createEmbed({ title: 'โธ๏ธ Paused', description: `Paused **${player.currentSong?.title}**`, color: 'warning' })], - }); - } + const lines = results.map((r, i) => + `**${i + 1}.** [${r.title}](${r.url})\nโ”— ๐Ÿ•’ \`${r.duration}\` โ€ข ๐Ÿ“บ ${r.channel}` + ); - if (sub === 'resume') { - if (!player.isPaused) { - return InteractionHelper.safeReply(interaction, { - embeds: [errorEmbed('Not Paused', 'Nothing is paused right now.')], - ephemeral: true, - }); - } - player.resume(); - return InteractionHelper.safeReply(interaction, { - embeds: [createEmbed({ title: 'โ–ถ๏ธ Resumed', description: `Resumed **${player.currentSong?.title}**`, color: 'success' })], - }); + const embed = new EmbedBuilder() + .setColor(0xFF0000) + .setTitle('๐ŸŽต Music Search Results') + .setDescription(lines.join('\n\n')) + .setFooter({ text: `Results for: ${query}` }) + .setTimestamp(); + + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); + } catch (error) { + await handleInteractionError(interaction, error, { commandName: 'music' }); } }, }; diff --git a/src/commands/Music/play.js b/src/commands/Music/play.js deleted file mode 100644 index 9b32cd3704..0000000000 --- a/src/commands/Music/play.js +++ /dev/null @@ -1,93 +0,0 @@ -import { SlashCommandBuilder, ChannelType, EmbedBuilder } from 'discord.js'; -import { errorEmbed } from '../../utils/embeds.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; -import { getOrCreatePlayer, searchSong, buildNowPlayingEmbed, buildPlayerRow } from '../../services/musicPlayer.js'; - -export default { - data: new SlashCommandBuilder() - .setName('play') - .setDescription('Play a song in a voice channel') - .addStringOption(o => o.setName('song').setDescription('Song name or YouTube URL').setRequired(true)) - .addChannelOption(o => - o.setName('channel') - .setDescription('Voice channel to join (defaults to your current VC)') - .addChannelTypes(ChannelType.GuildVoice) - ), - - category: 'Music', - - async execute(interaction) { - try { - await InteractionHelper.safeDefer(interaction); - - const query = interaction.options.getString('song'); - const channelOption = interaction.options.getChannel('channel'); - const voiceChannel = channelOption ?? interaction.member.voice.channel; - - if (!voiceChannel) { - return InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('No Voice Channel', 'Join a voice channel or specify one with the `channel` option.')], - }); - } - - const botPerms = voiceChannel.permissionsFor(interaction.guild.members.me); - if (!botPerms.has('Connect') || !botPerms.has('Speak')) { - return InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('Missing Permissions', 'I need **Connect** and **Speak** permissions in that channel.')], - }); - } - - const song = await searchSong(query); - if (!song) { - return InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('Not Found', `Could not find a song for: \`${query}\``)], - }); - } - - song.requestedBy = interaction.user.username; - - const player = getOrCreatePlayer(interaction.guildId); - - if (!player.connection) { - try { - await player.connect(voiceChannel); - } catch { - return InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('Connection Failed', `Could not connect to **${voiceChannel.name}**. Check my permissions and try again.`)], - }); - } - } - - const started = await player.addSong(song); - - if (started) { - // Show the full player embed with controls - const msg = await InteractionHelper.safeEditReply(interaction, { - embeds: [buildNowPlayingEmbed(song)], - components: [buildPlayerRow(false)], - }); - player.playerMessage = msg; - } else { - // Song was queued โ€” show a smaller confirmation - const queued = new EmbedBuilder() - .setColor(0x5865F2) - .setTitle('โž• Added to Queue') - .setDescription(`**[${song.title}](${song.url})**`) - .addFields( - { name: 'Duration', value: song.durationFormatted, inline: true }, - { name: 'Position', value: `#${player.songs.length}`, inline: true }, - ) - .setFooter({ text: `Requested by ${song.requestedBy}` }); - if (song.thumbnail) queued.setThumbnail(song.thumbnail); - await InteractionHelper.safeEditReply(interaction, { embeds: [queued] }); - } - - logger.info(`Music: ${interaction.user.tag} queued "${song.title}" in ${interaction.guild.name}`); - } catch (error) { - logger.error('Play command error:', error); - await handleInteractionError(interaction, error, { commandName: 'play' }); - } - }, -}; diff --git a/src/config/changelog.js b/src/config/changelog.js index 518e42668c..d4c5c73fb9 100644 --- a/src/config/changelog.js +++ b/src/config/changelog.js @@ -13,9 +13,8 @@ export const changelog = [ version: '2.6.0', date: '2026-05-05', entries: [ - { type: 'new', text: 'Added music system โ€” `/play`, `/stop`, `/skip`, `/queue`, `/pause`, `/resume`' }, - { type: 'new', text: '`/play` lets you pick any voice channel to join, or defaults to your current VC. Supports song names and YouTube URLs.' }, - { type: 'new', text: 'Queue system โ€” songs stack up and play in order. Bot auto-disconnects after 2 minutes of inactivity.' }, + { 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' }, ], }, { diff --git a/src/interactions/buttons/music.js b/src/interactions/buttons/music.js deleted file mode 100644 index acce848661..0000000000 --- a/src/interactions/buttons/music.js +++ /dev/null @@ -1,95 +0,0 @@ -import { EmbedBuilder } from 'discord.js'; -import { getPlayer, buildNowPlayingEmbed, buildPlayerRow } from '../../services/musicPlayer.js'; -import { errorEmbed } from '../../utils/embeds.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -function noPlayerReply(interaction) { - return InteractionHelper.safeReply(interaction, { - embeds: [errorEmbed('Not Playing', 'Nothing is playing right now.')], - ephemeral: true, - }); -} - -export default [ - { - name: 'music-pause', - async execute(interaction) { - const player = getPlayer(interaction.guildId); - if (!player?.currentSong) return noPlayerReply(interaction); - - if (player.isPlaying) { - player.pause(); - } else if (player.isPaused) { - player.resume(); - } - - const isPaused = player.isPaused; - await interaction.update({ - embeds: [buildNowPlayingEmbed(player.currentSong, isPaused)], - components: [buildPlayerRow(isPaused)], - }); - }, - }, - { - name: 'music-skip', - async execute(interaction) { - const player = getPlayer(interaction.guildId); - if (!player?.currentSong) return noPlayerReply(interaction); - - const skipped = player.currentSong.title; - player.skip(); - - await interaction.update({ - embeds: [new EmbedBuilder() - .setColor(0x5C5C5C) - .setDescription(`โญ๏ธ Skipped **${skipped}**`) - .setTimestamp()], - components: [], - }); - }, - }, - { - name: 'music-stop', - async execute(interaction) { - const player = getPlayer(interaction.guildId); - if (!player) return noPlayerReply(interaction); - - player.songs = []; - player.playerMessage = null; - player.destroy(); - - await interaction.update({ - embeds: [new EmbedBuilder() - .setColor(0x5C5C5C) - .setTitle('โน๏ธ Stopped') - .setDescription('Stopped playback and left the voice channel.') - .setTimestamp()], - components: [], - }); - }, - }, - { - name: 'music-shuffle', - async execute(interaction) { - const player = getPlayer(interaction.guildId); - if (!player?.currentSong) return noPlayerReply(interaction); - - if (player.songs.length < 2) { - return InteractionHelper.safeReply(interaction, { - embeds: [errorEmbed('Nothing to Shuffle', 'Need at least 2 songs in the queue to shuffle.')], - ephemeral: true, - }); - } - - player.shuffle(); - - await interaction.reply({ - embeds: [new EmbedBuilder() - .setColor(0x5865F2) - .setDescription(`๐Ÿ”€ Queue shuffled! (${player.songs.length} songs)`) - .setTimestamp()], - ephemeral: true, - }); - }, - }, -]; diff --git a/src/services/musicPlayer.js b/src/services/musicPlayer.js deleted file mode 100644 index cbcb54668e..0000000000 --- a/src/services/musicPlayer.js +++ /dev/null @@ -1,250 +0,0 @@ -import { - joinVoiceChannel, - createAudioPlayer, - createAudioResource, - AudioPlayerStatus, - VoiceConnectionStatus, - StreamType, -} from '@discordjs/voice'; -import { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; -import { spawn } from 'child_process'; -import { logger } from '../utils/logger.js'; - -const queues = new Map(); - -function formatDuration(seconds) { - if (!seconds) return '??:??'; - const h = Math.floor(seconds / 3600); - const m = Math.floor((seconds % 3600) / 60); - const s = seconds % 60; - if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; - return `${m}:${String(s).padStart(2, '0')}`; -} - -export function buildNowPlayingEmbed(song, isPaused = false) { - const embed = new EmbedBuilder() - .setColor(0xE5000E) - .setTitle(isPaused ? 'โธ๏ธ Paused' : '๐ŸŽต Now Playing') - .setDescription(`**[${song.title}](${song.url})**`) - .addFields( - { name: 'Duration', value: song.durationFormatted, inline: true }, - { name: 'Source', value: `[YouTube](${song.url})`, inline: true }, - ) - .setFooter({ text: `Requested by ${song.requestedBy ?? 'Unknown'}` }) - .setTimestamp(); - if (song.thumbnail) embed.setThumbnail(song.thumbnail); - return embed; -} - -export function buildPlayerRow(isPaused = false) { - return new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId('music-pause') - .setEmoji(isPaused ? 'โ–ถ๏ธ' : 'โธ๏ธ') - .setLabel(isPaused ? 'Resume' : 'Pause') - .setStyle(isPaused ? ButtonStyle.Success : ButtonStyle.Primary), - new ButtonBuilder() - .setCustomId('music-skip') - .setEmoji('โญ๏ธ') - .setLabel('Skip') - .setStyle(ButtonStyle.Secondary), - new ButtonBuilder() - .setCustomId('music-stop') - .setEmoji('โน๏ธ') - .setLabel('Stop') - .setStyle(ButtonStyle.Danger), - new ButtonBuilder() - .setCustomId('music-shuffle') - .setEmoji('๐Ÿ”€') - .setLabel('Shuffle') - .setStyle(ButtonStyle.Secondary), - ); -} - -export function searchSong(query) { - const isUrl = /^https?:\/\//i.test(query); - const searchArg = isUrl ? query : `ytsearch1:${query}`; - - return new Promise((resolve) => { - const proc = spawn('yt-dlp', [ - '--no-playlist', '--quiet', '--no-warnings', - '--js-runtimes', 'node', - '-j', searchArg, - ]); - - let out = ''; - proc.stdout.on('data', chunk => { out += chunk; }); - proc.stderr.on('data', chunk => logger.warn('yt-dlp search stderr:', chunk.toString().trim())); - proc.on('close', code => { - if (code !== 0 || !out.trim()) return resolve(null); - try { - const info = JSON.parse(out.trim()); - resolve({ - title: info.title ?? 'Unknown', - url: info.webpage_url ?? `https://www.youtube.com/watch?v=${info.id}`, - duration: info.duration ?? 0, - durationFormatted: formatDuration(info.duration ?? 0), - thumbnail: info.thumbnail ?? null, - }); - } catch { - resolve(null); - } - }); - proc.on('error', err => { - logger.error('yt-dlp search error:', err); - resolve(null); - }); - }); -} - -function createYtdlpStream(url) { - const proc = spawn('yt-dlp', [ - '-f', 'bestaudio[ext=webm]/bestaudio[ext=m4a]/bestaudio', - '--no-playlist', '--quiet', '--no-warnings', - '--js-runtimes', 'node', - '-o', '-', url, - ], { stdio: ['ignore', 'pipe', 'pipe'] }); - - proc.stderr.on('data', chunk => logger.warn('yt-dlp stream stderr:', chunk.toString().trim())); - proc.on('error', err => logger.error('yt-dlp spawn error:', err)); - - return proc.stdout; -} - -class GuildMusicPlayer { - constructor(guildId) { - this.guildId = guildId; - this.songs = []; - this.currentSong = null; - this.connection = null; - this.playerMessage = null; - this._idleTimer = null; - this.audioPlayer = createAudioPlayer(); - - this.audioPlayer.on(AudioPlayerStatus.Idle, () => { - if (this.songs.length > 0) { - this._playNext(); - } else { - this.currentSong = null; - this._updateMessageQueueEnded(); - this._idleTimer = setTimeout(() => this.destroy(), 120_000); - } - }); - - this.audioPlayer.on('error', (err) => { - logger.error(`AudioPlayer error in guild ${guildId}:`, err.message); - this._playNext(); - }); - } - - connect(voiceChannel) { - this.connection = joinVoiceChannel({ - channelId: voiceChannel.id, - guildId: voiceChannel.guild.id, - adapterCreator: voiceChannel.guild.voiceAdapterCreator, - selfDeaf: true, - }); - this.connection.subscribe(this.audioPlayer); - this.connection.on(VoiceConnectionStatus.Disconnected, () => { - logger.warn(`Voice disconnected in guild ${this.guildId}`); - this.destroy(); - }); - this.connection.on(VoiceConnectionStatus.Destroyed, () => queues.delete(this.guildId)); - - // Wait for the UDP connection to be ready before resolving - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error('Voice connection timed out')); - }, 20_000); - - this.connection.once(VoiceConnectionStatus.Ready, () => { - clearTimeout(timeout); - logger.info(`Voice ready in guild ${this.guildId}`); - resolve(); - }); - - this.connection.once(VoiceConnectionStatus.Destroyed, () => { - clearTimeout(timeout); - reject(new Error('Voice connection destroyed')); - }); - }); - } - - _playNext() { - if (!this.songs.length) { - this.currentSong = null; - return; - } - this.currentSong = this.songs.shift(); - logger.info(`Streaming: "${this.currentSong.title}"`); - - try { - const stream = createYtdlpStream(this.currentSong.url); - const resource = createAudioResource(stream, { inputType: StreamType.Arbitrary }); - this.audioPlayer.play(resource); - this._updateMessage(); - } catch (err) { - logger.error(`Stream error for "${this.currentSong.title}":`, err); - this._playNext(); - } - } - - async addSong(song) { - if (this._idleTimer) { - clearTimeout(this._idleTimer); - this._idleTimer = null; - } - this.songs.push(song); - const idle = this.audioPlayer.state.status === AudioPlayerStatus.Idle; - if (idle) { - this._playNext(); - return true; - } - return false; - } - - shuffle() { - for (let i = this.songs.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [this.songs[i], this.songs[j]] = [this.songs[j], this.songs[i]]; - } - } - - skip() { this.audioPlayer.stop(); } - pause() { this.audioPlayer.pause(); } - resume() { this.audioPlayer.unpause(); } - - _updateMessage(isPaused = false) { - if (!this.playerMessage || !this.currentSong) return; - this.playerMessage.edit({ - embeds: [buildNowPlayingEmbed(this.currentSong, isPaused)], - components: [buildPlayerRow(isPaused)], - }).catch(() => {}); - } - - _updateMessageQueueEnded() { - if (!this.playerMessage) return; - const ended = new EmbedBuilder() - .setColor(0x5C5C5C) - .setTitle('โน๏ธ Queue Ended') - .setDescription('No more songs in the queue.') - .setTimestamp(); - this.playerMessage.edit({ embeds: [ended], components: [] }).catch(() => {}); - this.playerMessage = null; - } - - destroy() { - try { this.connection?.destroy(); } catch {} - if (this._idleTimer) clearTimeout(this._idleTimer); - queues.delete(this.guildId); - } - - get isPlaying() { return this.audioPlayer.state.status === AudioPlayerStatus.Playing; } - get isPaused() { return this.audioPlayer.state.status === AudioPlayerStatus.Paused; } -} - -export function getPlayer(guildId) { return queues.get(guildId); } -export function getOrCreatePlayer(guildId) { - if (!queues.has(guildId)) queues.set(guildId, new GuildMusicPlayer(guildId)); - return queues.get(guildId); -} From 4e30522a36781cc1bb200f0b20fc90a947f924ed Mon Sep 17 00:00:00 2001 From: Itay100K Date: Tue, 5 May 2026 18:36:44 +0300 Subject: [PATCH 048/275] Add Gorilla Tag status, cosmetics, and room tracker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /gorilla setup: creates a read-only #๐Ÿฆใƒปserver-status channel that auto-edits every 5 minutes with live server status and Steam player count - /gorilla status: manual on-demand status check - /gorilla cosmetics: searchable browser of 35+ cosmetics by name/category - /gorilla rooms add/remove/list: track up to 10 room codes; each update shows live player counts per room using the PlayFab community API - Alerts in the status channel when server status degrades Co-Authored-By: Claude Sonnet 4.6 --- src/app.js | 2 + src/commands/Core/help.js | 1 + src/commands/Gorilla/gorilla.js | 245 ++++++++++++++++++++++++++++++++ src/config/changelog.js | 32 +++-- src/services/gorillaService.js | 157 ++++++++++++++++++++ 5 files changed, 427 insertions(+), 10 deletions(-) create mode 100644 src/commands/Gorilla/gorilla.js create mode 100644 src/services/gorillaService.js diff --git a/src/app.js b/src/app.js index 5d9db52356..63c267b467 100644 --- a/src/app.js +++ b/src/app.js @@ -12,6 +12,7 @@ import { logger, startupLog, shutdownLog } from './utils/logger.js'; import { checkBirthdays } from './services/birthdayService.js'; import { checkGiveaways } from './services/giveawayService.js'; import { checkTempRoles } from './services/tempRoleService.js'; +import { checkAndUpdateGorillaStatus } from './services/gorillaService.js'; import { loadCommands, registerCommands as registerSlashCommands } from './handlers/commandLoader.js'; class TitanBot extends Client { @@ -233,6 +234,7 @@ class TitanBot extends Client { cron.schedule('* * * * *', () => checkGiveaways(this)); cron.schedule('* * * * *', () => checkTempRoles(this)); cron.schedule('*/15 * * * *', () => this.updateAllCounters()); + cron.schedule('*/5 * * * *', () => checkAndUpdateGorillaStatus(this)); } async updateAllCounters() { diff --git a/src/commands/Core/help.js b/src/commands/Core/help.js index e18784458a..bf6cd35a73 100644 --- a/src/commands/Core/help.js +++ b/src/commands/Core/help.js @@ -38,6 +38,7 @@ const CATEGORY_INFO = { Logging: { icon: "๐Ÿ“‹", desc: "Server event logging and audit trails" }, Voice: { icon: "๐Ÿ”Š", desc: "Voice channel commands and activities" }, Music: { icon: "๐ŸŽต", desc: "Search for songs and get YouTube links" }, + Gorilla: { icon: "๐Ÿฆ", desc: "Gorilla Tag server status, cosmetics browser, and auto-update channel" }, }; export async function createInitialHelpMenu(client, member) { diff --git a/src/commands/Gorilla/gorilla.js b/src/commands/Gorilla/gorilla.js new file mode 100644 index 0000000000..5287d2af24 --- /dev/null +++ b/src/commands/Gorilla/gorilla.js @@ -0,0 +1,245 @@ +import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, ChannelType } from 'discord.js'; +import { errorEmbed } from '../../utils/embeds.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { handleInteractionError } from '../../utils/errorHandler.js'; +import { fetchGorillaStatus, fetchRoomCounts, buildStatusEmbed } from '../../services/gorillaService.js'; + +const COSMETICS = [ + { name: 'Top Hat', category: 'Hat', emoji: '๐ŸŽฉ', description: 'A classy top hat for the distinguished gorilla.' }, + { name: 'Cowboy Hat', category: 'Hat', emoji: '๐Ÿค ', description: 'Yeehaw, partner.' }, + { name: 'Birthday Hat', category: 'Hat', emoji: '๐ŸŽ‚', description: 'Happy birthday! Available during anniversary events.' }, + { name: 'Santa Hat', category: 'Hat', emoji: '๐ŸŽ…', description: 'Ho ho ho. A festive seasonal item.' }, + { name: 'Pumpkin', category: 'Hat', emoji: '๐ŸŽƒ', description: 'Spooky season never ends.' }, + { name: 'Crown', category: 'Hat', emoji: '๐Ÿ‘‘', description: 'For the royalty among gorillas.' }, + { name: 'Antlers', category: 'Hat', emoji: '๐ŸฆŒ', description: 'Jingle all the way.' }, + { name: 'Party Hat', category: 'Hat', emoji: '๐ŸŽ‰', description: 'Always ready to party.' }, + { name: 'Banana', category: 'Hat', emoji: '๐ŸŒ', description: 'Monke food, worn as a hat.' }, + { name: 'Pirate Hat', category: 'Hat', emoji: '๐Ÿดโ€โ˜ ๏ธ', description: 'Arrr matey, walk the plank.' }, + { name: 'Wizard Hat', category: 'Hat', emoji: '๐Ÿช„', description: 'You shall not pass.' }, + { name: 'Chef Hat', category: 'Hat', emoji: '๐Ÿ‘จโ€๐Ÿณ', description: 'Cooking up a storm in the trees.' }, + { name: 'Graduation Cap', category: 'Hat', emoji: '๐ŸŽ“', description: 'The most educated gorilla around.' }, + { name: 'Flower Crown', category: 'Hat', emoji: '๐ŸŒธ', description: 'Beautiful and in full bloom.' }, + { name: 'Propeller Hat', category: 'Hat', emoji: '๐ŸŒ€', description: 'Spin to win.' }, + { name: 'Space Helmet', category: 'Hat', emoji: '๐Ÿš€', description: 'One giant leap for gorillakind.' }, + { name: 'Hard Hat', category: 'Hat', emoji: '๐Ÿฆบ', description: 'Safety first, even in the jungle.' }, + { name: 'Cat Ears', category: 'Hat', emoji: '๐Ÿฑ', description: 'Meow.' }, + { name: 'Fox Ears', category: 'Hat', emoji: '๐ŸฆŠ', description: 'Sneaky and stylish.' }, + { name: 'Viking Helmet', category: 'Hat', emoji: 'โš”๏ธ', description: 'Pillage the forest.' }, + { name: 'Rainbow Mohawk', category: 'Hat', emoji: '๐ŸŒˆ', description: 'Very colourful, very aerodynamic.' }, + { name: 'Duck Hat', category: 'Hat', emoji: '๐Ÿฆ†', description: 'Quack.' }, + { name: 'Headphones', category: 'Hat', emoji: '๐ŸŽง', description: 'Vibe while you climb.' }, + { name: 'Halo', category: 'Hat', emoji: '๐Ÿ˜‡', description: 'An angelic gorilla.' }, + { name: 'Devil Horns', category: 'Hat', emoji: '๐Ÿ˜ˆ', description: 'The dark side of the jungle.' }, + { name: 'Sunflower', category: 'Hat', emoji: '๐ŸŒป', description: 'Blooming lovely.' }, + { name: 'Mod Stick', category: 'Badge', emoji: '๐Ÿ”จ', description: 'Held by Gorilla Tag moderators.' }, + { name: 'Dev Badge', category: 'Badge', emoji: '๐Ÿ› ๏ธ', description: 'Exclusive to the developers of Gorilla Tag.' }, + { name: 'Donor Badge', category: 'Badge', emoji: '๐Ÿ’Ž', description: 'For supporters of the game.' }, + { name: 'Left Shark', category: 'Costume', emoji: '๐Ÿฆˆ', description: 'You know the one.' }, + { name: 'Ghost', category: 'Costume', emoji: '๐Ÿ‘ป', description: 'Spooky scary.' }, + { name: 'Banana Suit', category: 'Costume', emoji: '๐ŸŒ', description: 'Full banana mode activated.' }, + { name: 'Astronaut Suit', category: 'Costume', emoji: '๐Ÿ‘จโ€๐Ÿš€', description: 'To infinity and beyond.' }, + { name: 'Candy Corn', category: 'Hat', emoji: '๐Ÿฌ', description: 'Sweet Halloween treat.' }, + { name: 'Elf Hat', category: 'Hat', emoji: '๐Ÿง', description: 'Helper of Santa Gorilla.' }, +]; + +export default { + data: new SlashCommandBuilder() + .setName('gorilla') + .setDescription('Gorilla Tag tools') + .addSubcommand(s => s + .setName('setup') + .setDescription('Create a server-status channel for Gorilla Tag and start auto-updates') + .addChannelOption(o => o + .setName('category') + .setDescription('Category to create the channel in (optional)') + .addChannelTypes(ChannelType.GuildCategory) + ) + ) + .addSubcommand(s => s + .setName('status') + .setDescription('Check Gorilla Tag server status right now') + ) + .addSubcommand(s => s + .setName('cosmetics') + .setDescription('Browse Gorilla Tag cosmetics') + .addStringOption(o => o + .setName('search') + .setDescription('Search by name or category (Hat, Badge, Costume)') + ) + ) + .addSubcommand(s => s + .setName('rooms') + .setDescription('Add or remove room codes to track player counts') + .addStringOption(o => o + .setName('action') + .setDescription('What to do') + .setRequired(true) + .addChoices( + { name: 'add', value: 'add' }, + { name: 'remove', value: 'remove' }, + { name: 'list', value: 'list' }, + ) + ) + .addStringOption(o => o + .setName('code') + .setDescription('Room code (e.g. IL34)') + ) + ) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild), + + category: 'Gorilla', + + async execute(interaction, _guildConfig, client) { + try { + const sub = interaction.options.getSubcommand(); + + if (sub === 'status') { + await InteractionHelper.safeDefer(interaction); + const data = await client.db.get(`gorilla:${interaction.guildId}`); + const [status, roomResults] = await Promise.all([ + fetchGorillaStatus(), + fetchRoomCounts(client.user.id, data?.rooms ?? []), + ]); + return InteractionHelper.safeEditReply(interaction, { embeds: [buildStatusEmbed(status, roomResults)] }); + } + + if (sub === 'rooms') { + const action = interaction.options.getString('action'); + const code = interaction.options.getString('code')?.toUpperCase().trim(); + const data = await client.db.get(`gorilla:${interaction.guildId}`) ?? {}; + const rooms = data.rooms ?? []; + + if (action === 'list') { + const embed = new EmbedBuilder() + .setColor(0x8B4513) + .setTitle('๐Ÿ  Tracked Room Codes') + .setDescription(rooms.length ? rooms.map(r => `\`${r}\``).join(' ') : 'No rooms tracked yet. Use `/gorilla rooms add ` to add one.') + .setFooter({ text: `${rooms.length}/10 rooms` }); + return InteractionHelper.safeReply(interaction, { embeds: [embed], ephemeral: true }); + } + + if (!code) { + return InteractionHelper.safeReply(interaction, { + embeds: [errorEmbed('Missing Code', 'Provide a room code, e.g. `IL34`')], + ephemeral: true, + }); + } + + if (action === 'add') { + if (rooms.includes(code)) { + return InteractionHelper.safeReply(interaction, { + embeds: [errorEmbed('Already Added', `\`${code}\` is already being tracked.`)], + ephemeral: true, + }); + } + if (rooms.length >= 10) { + return InteractionHelper.safeReply(interaction, { + embeds: [errorEmbed('Limit Reached', 'You can track up to 10 room codes.')], + ephemeral: true, + }); + } + rooms.push(code); + await client.db.set(`gorilla:${interaction.guildId}`, { ...data, rooms }); + return InteractionHelper.safeReply(interaction, { + embeds: [new EmbedBuilder().setColor(0x57F287).setDescription(`โœ… Now tracking room \`${code}\`.`)], + ephemeral: true, + }); + } + + if (action === 'remove') { + if (!rooms.includes(code)) { + return InteractionHelper.safeReply(interaction, { + embeds: [errorEmbed('Not Found', `\`${code}\` is not in the tracking list.`)], + ephemeral: true, + }); + } + const updated = rooms.filter(r => r !== code); + await client.db.set(`gorilla:${interaction.guildId}`, { ...data, rooms: updated }); + return InteractionHelper.safeReply(interaction, { + embeds: [new EmbedBuilder().setColor(0x57F287).setDescription(`โœ… Removed \`${code}\` from tracking.`)], + ephemeral: true, + }); + } + } + + if (sub === 'cosmetics') { + const query = interaction.options.getString('search')?.toLowerCase() ?? ''; + const filtered = query + ? COSMETICS.filter(c => c.name.toLowerCase().includes(query) || c.category.toLowerCase().includes(query)) + : COSMETICS; + + if (!filtered.length) { + return InteractionHelper.safeReply(interaction, { + embeds: [errorEmbed('No Results', `No cosmetics found for \`${query}\``)], + ephemeral: true, + }); + } + + const lines = filtered.slice(0, 20).map(c => + `${c.emoji} **${c.name}** \`${c.category}\`\nโ”— ${c.description}` + ); + + const embed = new EmbedBuilder() + .setColor(0x8B4513) + .setTitle('๐Ÿฆ Gorilla Tag Cosmetics') + .setDescription(lines.join('\n\n')) + .setFooter({ text: `${filtered.length} result(s)${filtered.length > 20 ? ' โ€” showing first 20' : ''}` }); + + return InteractionHelper.safeReply(interaction, { embeds: [embed] }); + } + + if (sub === 'setup') { + await InteractionHelper.safeDefer(interaction, { ephemeral: true }); + + const existing = await client.db.get(`gorilla:${interaction.guildId}`); + if (existing?.channelId) { + const ch = await interaction.guild.channels.fetch(existing.channelId).catch(() => null); + if (ch) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Already Set Up', `Status channel is already <#${ch.id}>. Delete it first to reset.`)], + }); + } + } + + const category = interaction.options.getChannel('category') ?? null; + + const channel = await interaction.guild.channels.create({ + name: '๐Ÿฆใƒปserver-status', + type: ChannelType.GuildText, + parent: category?.id ?? null, + topic: 'Gorilla Tag server status โ€” auto-updated every 5 minutes', + permissionOverwrites: [ + { + id: interaction.guild.roles.everyone.id, + allow: ['ViewChannel'], + deny: ['SendMessages'], + }, + { + id: interaction.guild.members.me.id, + allow: ['SendMessages', 'ViewChannel', 'EmbedLinks'], + }, + ], + }); + + const status = await fetchGorillaStatus(); + const msg = await channel.send({ embeds: [buildStatusEmbed(status)] }); + + await client.db.set(`gorilla:${interaction.guildId}`, { + channelId: channel.id, + messageId: msg.id, + lastIndicator: status.indicator, + }); + + return InteractionHelper.safeEditReply(interaction, { + embeds: [new EmbedBuilder() + .setColor(0x57F287) + .setTitle('โœ… Gorilla Tag Status Set Up') + .setDescription(`Created ${channel} โ€” status will auto-update every 5 minutes.`) + .setTimestamp()], + }); + } + } catch (error) { + await handleInteractionError(interaction, error, { commandName: 'gorilla' }); + } + }, +}; diff --git a/src/config/changelog.js b/src/config/changelog.js index d4c5c73fb9..652822ed40 100644 --- a/src/config/changelog.js +++ b/src/config/changelog.js @@ -1,12 +1,11 @@ export const changelog = [ { - version: '2.5.4', - date: '2026-05-04', + version: '2.7.0', + date: '2026-05-05', 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' }, + { 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' }, ], }, { @@ -17,6 +16,16 @@ export const changelog = [ { 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', @@ -139,8 +148,11 @@ export const changelog = [ // Emoji prefix per entry type export const typeEmoji = { - new: '๐Ÿ†•', - changed: 'โœ๏ธ', - fixed: '๐Ÿ›', - removed: '๐Ÿ—‘๏ธ', + new: '๐Ÿ†•', + update: 'โœ๏ธ', + updated: 'โœ๏ธ', + changed: 'โœ๏ธ', + improved: 'โœ๏ธ', + fixed: '๐Ÿ›', + removed: '๐Ÿ—‘๏ธ', }; diff --git a/src/services/gorillaService.js b/src/services/gorillaService.js new file mode 100644 index 0000000000..03fd77ec04 --- /dev/null +++ b/src/services/gorillaService.js @@ -0,0 +1,157 @@ +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 PLAYFAB_TITLE = '63FDD'; +const PLAYFAB_BASE = `https://${PLAYFAB_TITLE}.playfabapi.com`; +const REGIONS = ['US', 'EU', 'USW', 'AS', 'AU', 'BR', 'JP']; + +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' }, +}; + +// In-memory PlayFab session token cache (valid ~24h, refresh after 23h) +let _pfToken = null; +let _pfTokenExpiry = 0; + +async function getPlayFabToken(botId) { + if (_pfToken && Date.now() < _pfTokenExpiry) return _pfToken; + try { + const res = await fetch(`${PLAYFAB_BASE}/Client/LoginWithCustomID`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ TitleId: PLAYFAB_TITLE, CustomId: `gt-status-bot-${botId}`, CreateAccount: true }), + }); + const data = await res.json(); + _pfToken = data.data?.SessionTicket ?? null; + _pfTokenExpiry = Date.now() + 23 * 60 * 60 * 1000; + return _pfToken; + } catch { + return null; + } +} + +async function getRoomPlayerCount(token, roomCode) { + const checks = REGIONS.map(async region => { + try { + const res = await fetch(`${PLAYFAB_BASE}/Client/GetSharedGroupData`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Authorization': token }, + body: JSON.stringify({ SharedGroupId: roomCode + region, GetMembers: true }), + }); + const data = await res.json(); + if (data.code === 200 && Array.isArray(data.data?.Members)) { + return { count: data.data.Members.length, region }; + } + } catch {} + return null; + }); + + const results = (await Promise.all(checks)).filter(Boolean); + if (!results.length) return null; + return results.reduce((best, r) => (r.count > best.count ? r : best)); +} + +export async function fetchGorillaStatus() { + const [statusRes, steamRes] = await Promise.all([ + fetch(GT_STATUS_URL).then(r => r.json()).catch(() => null), + fetch(STEAM_URL).then(r => r.json()).catch(() => null), + ]); + + const indicator = statusRes?.status?.indicator ?? 'none'; + const description = statusRes?.status?.description ?? 'Unknown'; + const steamCount = steamRes?.response?.player_count ?? null; + + return { indicator, description, steamCount }; +} + +export async function fetchRoomCounts(botId, roomCodes) { + if (!roomCodes?.length) return []; + const token = await getPlayFabToken(botId); + if (!token) return roomCodes.map(code => ({ code, count: null, region: null })); + + const results = await Promise.all( + roomCodes.map(async code => { + const result = await getRoomPlayerCount(token, code.toUpperCase()); + return { code: code.toUpperCase(), count: result?.count ?? null, region: result?.region ?? null }; + }) + ); + return results; +} + +export function buildStatusEmbed({ indicator, description, steamCount }, roomResults = []) { + 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 (roomResults.length) { + const lines = roomResults.map(r => { + if (r.count === null) return `\`${r.code}\` โ€” Unavailable`; + const bar = 'โ–ˆ'.repeat(Math.min(r.count, 10)) + 'โ–‘'.repeat(Math.max(0, 10 - r.count)); + return `\`${r.code}\` ${r.region ? `(${r.region})` : ''} โ€” **${r.count}** player${r.count !== 1 ? 's' : ''}\n${bar}`; + }); + embed.addFields({ name: '๐Ÿ  Room Player Counts', value: lines.join('\n\n'), inline: false }); + } + + return embed; +} + +export async function checkAndUpdateGorillaStatus(client) { + try { + const guilds = client.guilds.cache; + if (!guilds.size) return; + + const baseStatus = await fetchGorillaStatus(); + + 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; + + const roomResults = await fetchRoomCounts(client.user.id, data.rooms ?? []); + const embed = buildStatusEmbed(baseStatus, roomResults); + + 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 !== baseStatus.indicator && baseStatus.indicator !== 'none') { + const cfg = STATUS_CONFIG[baseStatus.indicator]; + await channel.send({ content: `${cfg.emoji} **Gorilla Tag status changed:** ${baseStatus.description}` }); + } + + await client.db.set(`gorilla:${guildId}`, { ...data, lastIndicator: baseStatus.indicator }); + continue; + } + } + + const msg = await channel.send({ embeds: [embed] }); + await client.db.set(`gorilla:${guildId}`, { ...data, messageId: msg.id, lastIndicator: baseStatus.indicator }); + } catch (err) { + logger.warn(`Gorilla status update failed for guild ${guildId}: ${err.message}`); + } + } + } catch (err) { + logger.error('checkAndUpdateGorillaStatus error:', err); + } +} From 40ffb59ba00b95c699c48d02842ecd81708c6355 Mon Sep 17 00:00:00 2001 From: Itay100K Date: Tue, 5 May 2026 18:42:52 +0300 Subject: [PATCH 049/275] Remove broken room player count feature PlayFab blocks anonymous authentication (403 NotAuthorizedByTitle), making room count lookups impossible without a real in-game session. Removed the /gorilla rooms subcommand and PlayFab code entirely. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/Gorilla/gorilla.js | 87 +------------------------- src/services/gorillaService.js | 104 +++++--------------------------- 2 files changed, 19 insertions(+), 172 deletions(-) diff --git a/src/commands/Gorilla/gorilla.js b/src/commands/Gorilla/gorilla.js index 5287d2af24..4e8a009f6b 100644 --- a/src/commands/Gorilla/gorilla.js +++ b/src/commands/Gorilla/gorilla.js @@ -2,7 +2,7 @@ import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, ChannelType } f import { errorEmbed } from '../../utils/embeds.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { handleInteractionError } from '../../utils/errorHandler.js'; -import { fetchGorillaStatus, fetchRoomCounts, buildStatusEmbed } from '../../services/gorillaService.js'; +import { fetchGorillaStatus, buildStatusEmbed } from '../../services/gorillaService.js'; const COSMETICS = [ { name: 'Top Hat', category: 'Hat', emoji: '๐ŸŽฉ', description: 'A classy top hat for the distinguished gorilla.' }, @@ -67,24 +67,6 @@ export default { .setDescription('Search by name or category (Hat, Badge, Costume)') ) ) - .addSubcommand(s => s - .setName('rooms') - .setDescription('Add or remove room codes to track player counts') - .addStringOption(o => o - .setName('action') - .setDescription('What to do') - .setRequired(true) - .addChoices( - { name: 'add', value: 'add' }, - { name: 'remove', value: 'remove' }, - { name: 'list', value: 'list' }, - ) - ) - .addStringOption(o => o - .setName('code') - .setDescription('Room code (e.g. IL34)') - ) - ) .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild), category: 'Gorilla', @@ -95,71 +77,8 @@ export default { if (sub === 'status') { await InteractionHelper.safeDefer(interaction); - const data = await client.db.get(`gorilla:${interaction.guildId}`); - const [status, roomResults] = await Promise.all([ - fetchGorillaStatus(), - fetchRoomCounts(client.user.id, data?.rooms ?? []), - ]); - return InteractionHelper.safeEditReply(interaction, { embeds: [buildStatusEmbed(status, roomResults)] }); - } - - if (sub === 'rooms') { - const action = interaction.options.getString('action'); - const code = interaction.options.getString('code')?.toUpperCase().trim(); - const data = await client.db.get(`gorilla:${interaction.guildId}`) ?? {}; - const rooms = data.rooms ?? []; - - if (action === 'list') { - const embed = new EmbedBuilder() - .setColor(0x8B4513) - .setTitle('๐Ÿ  Tracked Room Codes') - .setDescription(rooms.length ? rooms.map(r => `\`${r}\``).join(' ') : 'No rooms tracked yet. Use `/gorilla rooms add ` to add one.') - .setFooter({ text: `${rooms.length}/10 rooms` }); - return InteractionHelper.safeReply(interaction, { embeds: [embed], ephemeral: true }); - } - - if (!code) { - return InteractionHelper.safeReply(interaction, { - embeds: [errorEmbed('Missing Code', 'Provide a room code, e.g. `IL34`')], - ephemeral: true, - }); - } - - if (action === 'add') { - if (rooms.includes(code)) { - return InteractionHelper.safeReply(interaction, { - embeds: [errorEmbed('Already Added', `\`${code}\` is already being tracked.`)], - ephemeral: true, - }); - } - if (rooms.length >= 10) { - return InteractionHelper.safeReply(interaction, { - embeds: [errorEmbed('Limit Reached', 'You can track up to 10 room codes.')], - ephemeral: true, - }); - } - rooms.push(code); - await client.db.set(`gorilla:${interaction.guildId}`, { ...data, rooms }); - return InteractionHelper.safeReply(interaction, { - embeds: [new EmbedBuilder().setColor(0x57F287).setDescription(`โœ… Now tracking room \`${code}\`.`)], - ephemeral: true, - }); - } - - if (action === 'remove') { - if (!rooms.includes(code)) { - return InteractionHelper.safeReply(interaction, { - embeds: [errorEmbed('Not Found', `\`${code}\` is not in the tracking list.`)], - ephemeral: true, - }); - } - const updated = rooms.filter(r => r !== code); - await client.db.set(`gorilla:${interaction.guildId}`, { ...data, rooms: updated }); - return InteractionHelper.safeReply(interaction, { - embeds: [new EmbedBuilder().setColor(0x57F287).setDescription(`โœ… Removed \`${code}\` from tracking.`)], - ephemeral: true, - }); - } + const status = await fetchGorillaStatus(); + return InteractionHelper.safeEditReply(interaction, { embeds: [buildStatusEmbed(status)] }); } if (sub === 'cosmetics') { diff --git a/src/services/gorillaService.js b/src/services/gorillaService.js index 03fd77ec04..eb9550e92c 100644 --- a/src/services/gorillaService.js +++ b/src/services/gorillaService.js @@ -1,11 +1,8 @@ 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 PLAYFAB_TITLE = '63FDD'; -const PLAYFAB_BASE = `https://${PLAYFAB_TITLE}.playfabapi.com`; -const REGIONS = ['US', 'EU', 'USW', 'AS', 'AU', 'BR', 'JP']; +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 STATUS_CONFIG = { none: { color: 0x57F287, emoji: '๐ŸŸข', label: 'All Systems Operational' }, @@ -14,79 +11,23 @@ const STATUS_CONFIG = { critical: { color: 0xED4245, emoji: '๐Ÿ”ด', label: 'Major System Outage' }, }; -// In-memory PlayFab session token cache (valid ~24h, refresh after 23h) -let _pfToken = null; -let _pfTokenExpiry = 0; - -async function getPlayFabToken(botId) { - if (_pfToken && Date.now() < _pfTokenExpiry) return _pfToken; - try { - const res = await fetch(`${PLAYFAB_BASE}/Client/LoginWithCustomID`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ TitleId: PLAYFAB_TITLE, CustomId: `gt-status-bot-${botId}`, CreateAccount: true }), - }); - const data = await res.json(); - _pfToken = data.data?.SessionTicket ?? null; - _pfTokenExpiry = Date.now() + 23 * 60 * 60 * 1000; - return _pfToken; - } catch { - return null; - } -} - -async function getRoomPlayerCount(token, roomCode) { - const checks = REGIONS.map(async region => { - try { - const res = await fetch(`${PLAYFAB_BASE}/Client/GetSharedGroupData`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-Authorization': token }, - body: JSON.stringify({ SharedGroupId: roomCode + region, GetMembers: true }), - }); - const data = await res.json(); - if (data.code === 200 && Array.isArray(data.data?.Members)) { - return { count: data.data.Members.length, region }; - } - } catch {} - return null; - }); - - const results = (await Promise.all(checks)).filter(Boolean); - if (!results.length) return null; - return results.reduce((best, r) => (r.count > best.count ? r : best)); -} - export async function fetchGorillaStatus() { const [statusRes, steamRes] = await Promise.all([ fetch(GT_STATUS_URL).then(r => r.json()).catch(() => null), fetch(STEAM_URL).then(r => r.json()).catch(() => null), ]); - const indicator = statusRes?.status?.indicator ?? 'none'; - const description = statusRes?.status?.description ?? 'Unknown'; - const steamCount = steamRes?.response?.player_count ?? null; - - return { indicator, description, steamCount }; -} - -export async function fetchRoomCounts(botId, roomCodes) { - if (!roomCodes?.length) return []; - const token = await getPlayFabToken(botId); - if (!token) return roomCodes.map(code => ({ code, count: null, region: null })); - - const results = await Promise.all( - roomCodes.map(async code => { - const result = await getRoomPlayerCount(token, code.toUpperCase()); - return { code: code.toUpperCase(), count: result?.count ?? null, region: result?.region ?? null }; - }) - ); - return results; + return { + indicator: statusRes?.status?.indicator ?? 'none', + description: statusRes?.status?.description ?? 'Unknown', + steamCount: steamRes?.response?.player_count ?? null, + }; } -export function buildStatusEmbed({ indicator, description, steamCount }, roomResults = []) { +export function buildStatusEmbed({ indicator, description, steamCount }) { const cfg = STATUS_CONFIG[indicator] ?? STATUS_CONFIG.none; - const embed = new EmbedBuilder() + return new EmbedBuilder() .setColor(cfg.color) .setTitle('๐Ÿฆ Gorilla Tag โ€” Server Status') .addFields( @@ -99,17 +40,6 @@ export function buildStatusEmbed({ indicator, description, steamCount }, roomRes ) .setFooter({ text: 'Updates every 5 minutes โ€ข status.gorilla.sc' }) .setTimestamp(); - - if (roomResults.length) { - const lines = roomResults.map(r => { - if (r.count === null) return `\`${r.code}\` โ€” Unavailable`; - const bar = 'โ–ˆ'.repeat(Math.min(r.count, 10)) + 'โ–‘'.repeat(Math.max(0, 10 - r.count)); - return `\`${r.code}\` ${r.region ? `(${r.region})` : ''} โ€” **${r.count}** player${r.count !== 1 ? 's' : ''}\n${bar}`; - }); - embed.addFields({ name: '๐Ÿ  Room Player Counts', value: lines.join('\n\n'), inline: false }); - } - - return embed; } export async function checkAndUpdateGorillaStatus(client) { @@ -117,7 +47,8 @@ export async function checkAndUpdateGorillaStatus(client) { const guilds = client.guilds.cache; if (!guilds.size) return; - const baseStatus = await fetchGorillaStatus(); + const status = await fetchGorillaStatus(); + const embed = buildStatusEmbed(status); for (const [guildId] of guilds) { const data = await client.db.get(`gorilla:${guildId}`); @@ -127,26 +58,23 @@ export async function checkAndUpdateGorillaStatus(client) { const channel = await client.channels.fetch(data.channelId).catch(() => null); if (!channel) continue; - const roomResults = await fetchRoomCounts(client.user.id, data.rooms ?? []); - const embed = buildStatusEmbed(baseStatus, roomResults); - 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 !== baseStatus.indicator && baseStatus.indicator !== 'none') { - const cfg = STATUS_CONFIG[baseStatus.indicator]; - await channel.send({ content: `${cfg.emoji} **Gorilla Tag status changed:** ${baseStatus.description}` }); + 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: baseStatus.indicator }); + 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: baseStatus.indicator }); + 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}`); } From 9841d2c351b2602abd6ea643e6cf1410b487d7e3 Mon Sep 17 00:00:00 2001 From: Itay100K Date: Tue, 5 May 2026 18:57:30 +0300 Subject: [PATCH 050/275] Add Gorilla Tag patch notes command and auto-poster MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /gorilla patchnotes [count] โ€” fetch latest 1-5 GT updates from Steam - Auto-posts new patch notes to the gorilla status channel whenever a new update is detected (checks every 30 minutes) Co-Authored-By: Claude Sonnet 4.6 --- src/app.js | 3 +- src/commands/Gorilla/gorilla.js | 26 ++++++++++++- src/config/changelog.js | 8 ++++ src/services/gorillaService.js | 67 +++++++++++++++++++++++++++++++++ 4 files changed, 102 insertions(+), 2 deletions(-) diff --git a/src/app.js b/src/app.js index 63c267b467..0d9e9ee8b9 100644 --- a/src/app.js +++ b/src/app.js @@ -12,7 +12,7 @@ import { logger, startupLog, shutdownLog } from './utils/logger.js'; import { checkBirthdays } from './services/birthdayService.js'; import { checkGiveaways } from './services/giveawayService.js'; import { checkTempRoles } from './services/tempRoleService.js'; -import { checkAndUpdateGorillaStatus } from './services/gorillaService.js'; +import { checkAndUpdateGorillaStatus, checkAndPostPatchNotes } from './services/gorillaService.js'; import { loadCommands, registerCommands as registerSlashCommands } from './handlers/commandLoader.js'; class TitanBot extends Client { @@ -235,6 +235,7 @@ class TitanBot extends Client { cron.schedule('* * * * *', () => checkTempRoles(this)); cron.schedule('*/15 * * * *', () => this.updateAllCounters()); cron.schedule('*/5 * * * *', () => checkAndUpdateGorillaStatus(this)); + cron.schedule('*/30 * * * *', () => checkAndPostPatchNotes(this)); } async updateAllCounters() { diff --git a/src/commands/Gorilla/gorilla.js b/src/commands/Gorilla/gorilla.js index 4e8a009f6b..6c63740a15 100644 --- a/src/commands/Gorilla/gorilla.js +++ b/src/commands/Gorilla/gorilla.js @@ -2,7 +2,7 @@ import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, ChannelType } f import { errorEmbed } from '../../utils/embeds.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { handleInteractionError } from '../../utils/errorHandler.js'; -import { fetchGorillaStatus, buildStatusEmbed } from '../../services/gorillaService.js'; +import { fetchGorillaStatus, buildStatusEmbed, fetchPatchNotes, buildPatchEmbed } from '../../services/gorillaService.js'; const COSMETICS = [ { name: 'Top Hat', category: 'Hat', emoji: '๐ŸŽฉ', description: 'A classy top hat for the distinguished gorilla.' }, @@ -59,6 +59,16 @@ export default { .setName('status') .setDescription('Check Gorilla Tag server status right now') ) + .addSubcommand(s => s + .setName('patchnotes') + .setDescription('Show the latest Gorilla Tag patch notes') + .addIntegerOption(o => o + .setName('count') + .setDescription('How many updates to show (1-5, default 1)') + .setMinValue(1) + .setMaxValue(5) + ) + ) .addSubcommand(s => s .setName('cosmetics') .setDescription('Browse Gorilla Tag cosmetics') @@ -81,6 +91,20 @@ export default { return InteractionHelper.safeEditReply(interaction, { embeds: [buildStatusEmbed(status)] }); } + if (sub === 'patchnotes') { + await InteractionHelper.safeDefer(interaction); + const count = interaction.options.getInteger('count') ?? 1; + const notes = await fetchPatchNotes(count); + if (!notes.length) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('No Results', 'Could not fetch patch notes from Steam.')], + }); + } + return InteractionHelper.safeEditReply(interaction, { + embeds: notes.map(buildPatchEmbed), + }); + } + if (sub === 'cosmetics') { const query = interaction.options.getString('search')?.toLowerCase() ?? ''; const filtered = query diff --git a/src/config/changelog.js b/src/config/changelog.js index 652822ed40..b2d3430567 100644 --- a/src/config/changelog.js +++ b/src/config/changelog.js @@ -1,4 +1,12 @@ export const changelog = [ + { + 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', diff --git a/src/services/gorillaService.js b/src/services/gorillaService.js index eb9550e92c..fa1bde77bf 100644 --- a/src/services/gorillaService.js +++ b/src/services/gorillaService.js @@ -3,6 +3,7 @@ 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' }, @@ -11,6 +12,15 @@ const STATUS_CONFIG = { 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] = await Promise.all([ fetch(GT_STATUS_URL).then(r => r.json()).catch(() => null), @@ -24,6 +34,33 @@ export async function fetchGorillaStatus() { }; } +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 }) { const cfg = STATUS_CONFIG[indicator] ?? STATUS_CONFIG.none; @@ -83,3 +120,33 @@ export async function checkAndUpdateGorillaStatus(client) { 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); + } +} From 4c73f3b8a4eae9d88af069afe74cb35109eac23d Mon Sep 17 00:00:00 2001 From: Itay100K Date: Tue, 5 May 2026 18:59:25 +0300 Subject: [PATCH 051/275] Reduce patch note check to once daily (9 AM UTC) GT updates every ~27 days, checking every 30 minutes was wasteful. Co-Authored-By: Claude Sonnet 4.6 --- src/app.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app.js b/src/app.js index 0d9e9ee8b9..64421a5fe2 100644 --- a/src/app.js +++ b/src/app.js @@ -235,7 +235,7 @@ class TitanBot extends Client { cron.schedule('* * * * *', () => checkTempRoles(this)); cron.schedule('*/15 * * * *', () => this.updateAllCounters()); cron.schedule('*/5 * * * *', () => checkAndUpdateGorillaStatus(this)); - cron.schedule('*/30 * * * *', () => checkAndPostPatchNotes(this)); + cron.schedule('0 9 * * *', () => checkAndPostPatchNotes(this)); } async updateAllCounters() { From 1337a0f731b17616790d2b7bda824937e06e4448 Mon Sep 17 00:00:00 2001 From: Itay100K Date: Tue, 5 May 2026 19:07:04 +0300 Subject: [PATCH 052/275] Remove /gorilla patchnotes command and auto-post patch notes feature Co-Authored-By: Claude Sonnet 4.6 --- src/app.js | 3 +- src/commands/Core/help.js | 2 +- src/commands/Gorilla/gorilla.js | 26 +------------ src/config/changelog.js | 3 +- src/services/gorillaService.js | 66 --------------------------------- 5 files changed, 4 insertions(+), 96 deletions(-) diff --git a/src/app.js b/src/app.js index 64421a5fe2..63c267b467 100644 --- a/src/app.js +++ b/src/app.js @@ -12,7 +12,7 @@ import { logger, startupLog, shutdownLog } from './utils/logger.js'; import { checkBirthdays } from './services/birthdayService.js'; import { checkGiveaways } from './services/giveawayService.js'; import { checkTempRoles } from './services/tempRoleService.js'; -import { checkAndUpdateGorillaStatus, checkAndPostPatchNotes } from './services/gorillaService.js'; +import { checkAndUpdateGorillaStatus } from './services/gorillaService.js'; import { loadCommands, registerCommands as registerSlashCommands } from './handlers/commandLoader.js'; class TitanBot extends Client { @@ -235,7 +235,6 @@ class TitanBot extends Client { cron.schedule('* * * * *', () => checkTempRoles(this)); cron.schedule('*/15 * * * *', () => this.updateAllCounters()); cron.schedule('*/5 * * * *', () => checkAndUpdateGorillaStatus(this)); - cron.schedule('0 9 * * *', () => checkAndPostPatchNotes(this)); } async updateAllCounters() { diff --git a/src/commands/Core/help.js b/src/commands/Core/help.js index bf6cd35a73..413959b512 100644 --- a/src/commands/Core/help.js +++ b/src/commands/Core/help.js @@ -38,7 +38,7 @@ const CATEGORY_INFO = { Logging: { icon: "๐Ÿ“‹", desc: "Server event logging and audit trails" }, Voice: { icon: "๐Ÿ”Š", desc: "Voice channel commands and activities" }, Music: { icon: "๐ŸŽต", desc: "Search for songs and get YouTube links" }, - Gorilla: { icon: "๐Ÿฆ", desc: "Gorilla Tag server status, cosmetics browser, and auto-update channel" }, + Gorilla: { icon: "๐Ÿฆ", desc: "Gorilla Tag server status and cosmetics browser" }, }; export async function createInitialHelpMenu(client, member) { diff --git a/src/commands/Gorilla/gorilla.js b/src/commands/Gorilla/gorilla.js index 6c63740a15..4e8a009f6b 100644 --- a/src/commands/Gorilla/gorilla.js +++ b/src/commands/Gorilla/gorilla.js @@ -2,7 +2,7 @@ import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, ChannelType } f import { errorEmbed } from '../../utils/embeds.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { handleInteractionError } from '../../utils/errorHandler.js'; -import { fetchGorillaStatus, buildStatusEmbed, fetchPatchNotes, buildPatchEmbed } from '../../services/gorillaService.js'; +import { fetchGorillaStatus, buildStatusEmbed } from '../../services/gorillaService.js'; const COSMETICS = [ { name: 'Top Hat', category: 'Hat', emoji: '๐ŸŽฉ', description: 'A classy top hat for the distinguished gorilla.' }, @@ -59,16 +59,6 @@ export default { .setName('status') .setDescription('Check Gorilla Tag server status right now') ) - .addSubcommand(s => s - .setName('patchnotes') - .setDescription('Show the latest Gorilla Tag patch notes') - .addIntegerOption(o => o - .setName('count') - .setDescription('How many updates to show (1-5, default 1)') - .setMinValue(1) - .setMaxValue(5) - ) - ) .addSubcommand(s => s .setName('cosmetics') .setDescription('Browse Gorilla Tag cosmetics') @@ -91,20 +81,6 @@ export default { return InteractionHelper.safeEditReply(interaction, { embeds: [buildStatusEmbed(status)] }); } - if (sub === 'patchnotes') { - await InteractionHelper.safeDefer(interaction); - const count = interaction.options.getInteger('count') ?? 1; - const notes = await fetchPatchNotes(count); - if (!notes.length) { - return InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('No Results', 'Could not fetch patch notes from Steam.')], - }); - } - return InteractionHelper.safeEditReply(interaction, { - embeds: notes.map(buildPatchEmbed), - }); - } - if (sub === 'cosmetics') { const query = interaction.options.getString('search')?.toLowerCase() ?? ''; const filtered = query diff --git a/src/config/changelog.js b/src/config/changelog.js index b2d3430567..c23b26a663 100644 --- a/src/config/changelog.js +++ b/src/config/changelog.js @@ -3,8 +3,7 @@ export const changelog = [ 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)' }, + { type: 'removed', text: 'Removed `/gorilla patchnotes` command and auto-post patch notes feature' }, ], }, { diff --git a/src/services/gorillaService.js b/src/services/gorillaService.js index fa1bde77bf..dec4421825 100644 --- a/src/services/gorillaService.js +++ b/src/services/gorillaService.js @@ -3,7 +3,6 @@ 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' }, @@ -12,15 +11,6 @@ const STATUS_CONFIG = { 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] = await Promise.all([ fetch(GT_STATUS_URL).then(r => r.json()).catch(() => null), @@ -34,33 +24,6 @@ export async function fetchGorillaStatus() { }; } -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 }) { const cfg = STATUS_CONFIG[indicator] ?? STATUS_CONFIG.none; @@ -121,32 +84,3 @@ export async function checkAndUpdateGorillaStatus(client) { } } -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); - } -} From 72062b6d80e9ea7c466ee83ba3d2026bcb016780 Mon Sep 17 00:00:00 2001 From: Itay100K Date: Tue, 5 May 2026 19:08:29 +0300 Subject: [PATCH 053/275] Revert "Remove /gorilla patchnotes command and auto-post patch notes feature" This reverts commit 1337a0f731b17616790d2b7bda824937e06e4448. --- src/app.js | 3 +- src/commands/Core/help.js | 2 +- src/commands/Gorilla/gorilla.js | 26 ++++++++++++- src/config/changelog.js | 3 +- src/services/gorillaService.js | 66 +++++++++++++++++++++++++++++++++ 5 files changed, 96 insertions(+), 4 deletions(-) diff --git a/src/app.js b/src/app.js index 63c267b467..64421a5fe2 100644 --- a/src/app.js +++ b/src/app.js @@ -12,7 +12,7 @@ import { logger, startupLog, shutdownLog } from './utils/logger.js'; import { checkBirthdays } from './services/birthdayService.js'; import { checkGiveaways } from './services/giveawayService.js'; import { checkTempRoles } from './services/tempRoleService.js'; -import { checkAndUpdateGorillaStatus } from './services/gorillaService.js'; +import { checkAndUpdateGorillaStatus, checkAndPostPatchNotes } from './services/gorillaService.js'; import { loadCommands, registerCommands as registerSlashCommands } from './handlers/commandLoader.js'; class TitanBot extends Client { @@ -235,6 +235,7 @@ class TitanBot extends Client { cron.schedule('* * * * *', () => checkTempRoles(this)); cron.schedule('*/15 * * * *', () => this.updateAllCounters()); cron.schedule('*/5 * * * *', () => checkAndUpdateGorillaStatus(this)); + cron.schedule('0 9 * * *', () => checkAndPostPatchNotes(this)); } async updateAllCounters() { diff --git a/src/commands/Core/help.js b/src/commands/Core/help.js index 413959b512..bf6cd35a73 100644 --- a/src/commands/Core/help.js +++ b/src/commands/Core/help.js @@ -38,7 +38,7 @@ const CATEGORY_INFO = { Logging: { icon: "๐Ÿ“‹", desc: "Server event logging and audit trails" }, Voice: { icon: "๐Ÿ”Š", desc: "Voice channel commands and activities" }, Music: { icon: "๐ŸŽต", desc: "Search for songs and get YouTube links" }, - Gorilla: { icon: "๐Ÿฆ", desc: "Gorilla Tag server status and cosmetics browser" }, + Gorilla: { icon: "๐Ÿฆ", desc: "Gorilla Tag server status, cosmetics browser, and auto-update channel" }, }; export async function createInitialHelpMenu(client, member) { diff --git a/src/commands/Gorilla/gorilla.js b/src/commands/Gorilla/gorilla.js index 4e8a009f6b..6c63740a15 100644 --- a/src/commands/Gorilla/gorilla.js +++ b/src/commands/Gorilla/gorilla.js @@ -2,7 +2,7 @@ import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, ChannelType } f import { errorEmbed } from '../../utils/embeds.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { handleInteractionError } from '../../utils/errorHandler.js'; -import { fetchGorillaStatus, buildStatusEmbed } from '../../services/gorillaService.js'; +import { fetchGorillaStatus, buildStatusEmbed, fetchPatchNotes, buildPatchEmbed } from '../../services/gorillaService.js'; const COSMETICS = [ { name: 'Top Hat', category: 'Hat', emoji: '๐ŸŽฉ', description: 'A classy top hat for the distinguished gorilla.' }, @@ -59,6 +59,16 @@ export default { .setName('status') .setDescription('Check Gorilla Tag server status right now') ) + .addSubcommand(s => s + .setName('patchnotes') + .setDescription('Show the latest Gorilla Tag patch notes') + .addIntegerOption(o => o + .setName('count') + .setDescription('How many updates to show (1-5, default 1)') + .setMinValue(1) + .setMaxValue(5) + ) + ) .addSubcommand(s => s .setName('cosmetics') .setDescription('Browse Gorilla Tag cosmetics') @@ -81,6 +91,20 @@ export default { return InteractionHelper.safeEditReply(interaction, { embeds: [buildStatusEmbed(status)] }); } + if (sub === 'patchnotes') { + await InteractionHelper.safeDefer(interaction); + const count = interaction.options.getInteger('count') ?? 1; + const notes = await fetchPatchNotes(count); + if (!notes.length) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('No Results', 'Could not fetch patch notes from Steam.')], + }); + } + return InteractionHelper.safeEditReply(interaction, { + embeds: notes.map(buildPatchEmbed), + }); + } + if (sub === 'cosmetics') { const query = interaction.options.getString('search')?.toLowerCase() ?? ''; const filtered = query diff --git a/src/config/changelog.js b/src/config/changelog.js index c23b26a663..b2d3430567 100644 --- a/src/config/changelog.js +++ b/src/config/changelog.js @@ -3,7 +3,8 @@ export const changelog = [ version: '2.7.1', date: '2026-05-05', entries: [ - { type: 'removed', text: 'Removed `/gorilla patchnotes` command and auto-post patch notes feature' }, + { 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)' }, ], }, { diff --git a/src/services/gorillaService.js b/src/services/gorillaService.js index dec4421825..fa1bde77bf 100644 --- a/src/services/gorillaService.js +++ b/src/services/gorillaService.js @@ -3,6 +3,7 @@ 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' }, @@ -11,6 +12,15 @@ const STATUS_CONFIG = { 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] = await Promise.all([ fetch(GT_STATUS_URL).then(r => r.json()).catch(() => null), @@ -24,6 +34,33 @@ export async function fetchGorillaStatus() { }; } +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 }) { const cfg = STATUS_CONFIG[indicator] ?? STATUS_CONFIG.none; @@ -84,3 +121,32 @@ export async function checkAndUpdateGorillaStatus(client) { } } +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); + } +} From ddc8764196c3d8c2de569c2d3c9ad9df39241c93 Mon Sep 17 00:00:00 2001 From: Itay100K Date: Tue, 5 May 2026 19:10:36 +0300 Subject: [PATCH 054/275] Add latest patch note field to Gorilla Tag status embed Co-Authored-By: Claude Sonnet 4.6 --- src/services/gorillaService.js | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/services/gorillaService.js b/src/services/gorillaService.js index fa1bde77bf..9badd609fc 100644 --- a/src/services/gorillaService.js +++ b/src/services/gorillaService.js @@ -22,15 +22,19 @@ function cleanContent(text) { } export async function fetchGorillaStatus() { - const [statusRes, steamRes] = await Promise.all([ + 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, }; } @@ -61,10 +65,10 @@ export function buildPatchEmbed(note) { .setTimestamp(note.date); } -export function buildStatusEmbed({ indicator, description, steamCount }) { +export function buildStatusEmbed({ indicator, description, steamCount, latestPatch }) { const cfg = STATUS_CONFIG[indicator] ?? STATUS_CONFIG.none; - return new EmbedBuilder() + const embed = new EmbedBuilder() .setColor(cfg.color) .setTitle('๐Ÿฆ Gorilla Tag โ€” Server Status') .addFields( @@ -77,6 +81,16 @@ export function buildStatusEmbed({ indicator, description, steamCount }) { ) .setFooter({ text: 'Updates every 5 minutes โ€ข status.gorilla.sc' }) .setTimestamp(); + + if (latestPatch) { + embed.addFields({ + name: '๐Ÿ“‹ Latest Update', + value: `[${latestPatch.title}](${latestPatch.url})\n`, + inline: false, + }); + } + + return embed; } export async function checkAndUpdateGorillaStatus(client) { From 0e6a0c5dab45b21770dfe0dc2eae9a1e8cc5272b Mon Sep 17 00:00:00 2001 From: Itay100K Date: Wed, 6 May 2026 22:08:55 +0300 Subject: [PATCH 055/275] Add voice music player with /play command and queue controls Co-Authored-By: Claude Sonnet 4.6 --- src/commands/Core/help.js | 2 +- src/commands/Music/play.js | 104 ++++++++++++++++ src/config/changelog.js | 9 ++ src/interactions/buttons/music.js | 92 ++++++++++++++ src/services/musicService.js | 192 ++++++++++++++++++++++++++++++ 5 files changed, 398 insertions(+), 1 deletion(-) create mode 100644 src/commands/Music/play.js create mode 100644 src/interactions/buttons/music.js create mode 100644 src/services/musicService.js diff --git a/src/commands/Core/help.js b/src/commands/Core/help.js index bf6cd35a73..b5c3e5738f 100644 --- a/src/commands/Core/help.js +++ b/src/commands/Core/help.js @@ -37,7 +37,7 @@ const CATEGORY_INFO = { Serverstats: { icon: "๐Ÿ“ˆ", desc: "Server statistics and display counters" }, Logging: { icon: "๐Ÿ“‹", desc: "Server event logging and audit trails" }, Voice: { icon: "๐Ÿ”Š", desc: "Voice channel commands and activities" }, - Music: { icon: "๐ŸŽต", desc: "Search for songs and get YouTube links" }, + Music: { icon: "๐ŸŽต", desc: "Play music in voice channels, queue songs, and control playback" }, Gorilla: { icon: "๐Ÿฆ", desc: "Gorilla Tag server status, cosmetics browser, and auto-update channel" }, }; diff --git a/src/commands/Music/play.js b/src/commands/Music/play.js new file mode 100644 index 0000000000..969229e817 --- /dev/null +++ b/src/commands/Music/play.js @@ -0,0 +1,104 @@ +import { SlashCommandBuilder, EmbedBuilder } from 'discord.js'; +import playdl from 'play-dl'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { errorEmbed } from '../../utils/embeds.js'; +import { handleInteractionError } from '../../utils/errorHandler.js'; +import { joinAndQueue, buildControls } from '../../services/musicService.js'; + +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; +} + +export default { + data: new SlashCommandBuilder() + .setName('play') + .setDescription('Play a song in your voice channel') + .addStringOption(o => + o.setName('song') + .setDescription('Song name or YouTube URL') + .setRequired(true) + ), + + category: 'Music', + + async execute(interaction) { + try { + await InteractionHelper.safeDefer(interaction); + + const voiceChannel = interaction.member?.voice?.channel; + if (!voiceChannel) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Not in Voice', 'You need to be in a voice channel to play music.')], + }); + } + + const query = interaction.options.getString('song'); + let song; + + if (/^https?:\/\//i.test(query)) { + const info = await playdl.video_info(query).catch(() => null); + if (!info) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Invalid URL', 'Could not load that YouTube URL.')], + }); + } + const v = info.video_details; + song = { + title: v.title, + url: query, + duration: v.durationRaw, + channel: v.channel?.name ?? 'Unknown', + requestedBy: interaction.user.toString(), + }; + } else { + const results = await playdl.search(query, { limit: 1, source: { youtube: 'video' } }); + if (!results.length) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Not Found', `No results for: \`${query}\``)], + }); + } + const v = results[0]; + song = { + title: v.title, + url: v.url, + duration: v.durationRaw, + channel: v.channel?.name ?? 'Unknown', + requestedBy: interaction.user.toString(), + }; + } + + const { queue, queued } = await joinAndQueue(song, voiceChannel, interaction.channel); + + const embed = new EmbedBuilder() + .setColor(0xFF0000) + .setAuthor({ name: queued ? '๐Ÿ“‹ Added to Queue' : '๐ŸŽต Now Playing' }) + .setTitle(song.title) + .setURL(song.url) + .addFields( + { name: 'โฑ Duration', value: song.duration, inline: true }, + { name: '๐Ÿ“บ Channel', value: song.channel, inline: true }, + { name: '๐Ÿ‘ค Requested by', value: song.requestedBy, inline: true }, + ) + .setTimestamp(); + + if (queued) { + embed.addFields({ name: '๐Ÿ”ข Position in Queue', value: `#${queue.songs.length}`, inline: true }); + } + + const thumb = getThumb(song.url); + if (thumb) embed.setThumbnail(thumb); + + const msg = await InteractionHelper.safeEditReply(interaction, { + embeds: [embed], + components: [buildControls(false)], + }); + + if (!queued && queue) { + queue.nowPlayingMsg = msg; + } + } catch (error) { + await handleInteractionError(interaction, error, { commandName: 'play' }); + } + }, +}; diff --git a/src/config/changelog.js b/src/config/changelog.js index b2d3430567..16c5ff92c0 100644 --- a/src/config/changelog.js +++ b/src/config/changelog.js @@ -1,4 +1,13 @@ export const changelog = [ + { + 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', diff --git a/src/interactions/buttons/music.js b/src/interactions/buttons/music.js new file mode 100644 index 0000000000..d2ed3ac8d7 --- /dev/null +++ b/src/interactions/buttons/music.js @@ -0,0 +1,92 @@ +import { EmbedBuilder } from 'discord.js'; +import { skipSong, stopMusic, togglePause, getQueue, buildControls } from '../../services/musicService.js'; + +export default [ + { + name: 'music_pause', + async execute(interaction) { + const queue = getQueue(interaction.guildId); + if (!queue?.current) { + return interaction.reply({ content: 'Nothing is playing right now.', ephemeral: true }); + } + if (interaction.member?.voice?.channelId !== queue.voiceChannel.id) { + return interaction.reply({ content: 'You need to be in the same voice channel.', ephemeral: true }); + } + + const nowPaused = togglePause(interaction.guildId); + await interaction.update({ components: [buildControls(nowPaused)] }); + }, + }, + { + name: 'music_skip', + async execute(interaction) { + const queue = getQueue(interaction.guildId); + if (!queue?.current) { + return interaction.reply({ content: 'Nothing is playing right now.', ephemeral: true }); + } + if (interaction.member?.voice?.channelId !== queue.voiceChannel.id) { + return interaction.reply({ content: 'You need to be in the same voice channel.', ephemeral: true }); + } + + skipSong(interaction.guildId); + + await interaction.update({ + embeds: [new EmbedBuilder().setColor(0xFEE75C).setDescription('โญ Skipped.')], + components: [], + }); + }, + }, + { + name: 'music_stop', + async execute(interaction) { + const queue = getQueue(interaction.guildId); + if (!queue) { + return interaction.reply({ content: 'Nothing is playing.', ephemeral: true }); + } + if (interaction.member?.voice?.channelId !== queue.voiceChannel.id) { + return interaction.reply({ content: 'You need to be in the same voice channel.', ephemeral: true }); + } + + stopMusic(interaction.guildId); + + await interaction.update({ + embeds: [new EmbedBuilder().setColor(0xED4245).setDescription('โน Stopped and queue cleared.')], + components: [], + }); + }, + }, + { + name: 'music_queue', + async execute(interaction) { + const queue = getQueue(interaction.guildId); + if (!queue) { + return interaction.reply({ content: 'No active queue for this server.', ephemeral: true }); + } + + const lines = []; + if (queue.current) { + lines.push(`**Now Playing:** [${queue.current.title}](${queue.current.url}) \`${queue.current.duration}\``); + } + if (queue.songs.length) { + lines.push('\n**Up Next:**'); + queue.songs.slice(0, 10).forEach((s, i) => { + lines.push(`**${i + 1}.** [${s.title}](${s.url}) \`${s.duration}\``); + }); + if (queue.songs.length > 10) { + lines.push(`*...and ${queue.songs.length - 10} more*`); + } + } + + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(0xFF0000) + .setTitle('๐ŸŽต Music Queue') + .setDescription(lines.join('\n') || 'The queue is empty.') + .setTimestamp(), + ], + ephemeral: true, + }); + }, + }, +]; 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 }; From 30f263f68dfbc1b04977fa760ce51ae54ce2348f Mon Sep 17 00:00:00 2001 From: Itay100K Date: Wed, 6 May 2026 22:18:02 +0300 Subject: [PATCH 056/275] Add Linux VPS docker-compose with network_mode host for voice support Co-Authored-By: Claude Sonnet 4.6 --- docker-compose.linux.yml | 52 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 docker-compose.linux.yml 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: From 8760e210105132e7d17850edfefb2fcec60a3e37 Mon Sep 17 00:00:00 2001 From: Itay100K Date: Thu, 7 May 2026 15:17:22 +0300 Subject: [PATCH 057/275] Add > mod prefix commands (ban, kick, warn, timeout, purge, lock, etc.) Co-Authored-By: Claude Sonnet 4.6 --- src/config/changelog.js | 12 +++ src/events/messageCreate.js | 187 ++++++++++++++++++++++++++++++++++++ 2 files changed, 199 insertions(+) diff --git a/src/config/changelog.js b/src/config/changelog.js index 16c5ff92c0..76af2503b7 100644 --- a/src/config/changelog.js +++ b/src/config/changelog.js @@ -1,4 +1,16 @@ export const changelog = [ + { + 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 `, `>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', diff --git a/src/events/messageCreate.js b/src/events/messageCreate.js index 1aab57fc87..008660c6f6 100644 --- a/src/events/messageCreate.js +++ b/src/events/messageCreate.js @@ -14,6 +14,7 @@ import { AntiNsfwService } from '../services/antiNsfwService.js'; const MESSAGE_XP_RATE_LIMIT_ATTEMPTS = 12; const MESSAGE_XP_RATE_LIMIT_WINDOW_MS = 10000; const PREFIX = '?'; +const MOD_PREFIX = '>'; export default { name: Events.MessageCreate, @@ -21,6 +22,11 @@ export default { try { if (message.author.bot || !message.guild) return; + if (message.content.startsWith(MOD_PREFIX)) { + await handleModCommand(message, client); + return; + } + if (message.content.startsWith(PREFIX)) { await handlePrefixCommand(message, client); return; @@ -44,6 +50,187 @@ export default { +function parseDuration(str) { + const match = str?.match(/^(\d+)(s|m|h|d)$/i); + if (!match) return null; + const multipliers = { s: 1000, m: 60000, h: 3600000, d: 86400000 }; + const ms = parseInt(match[1]) * multipliers[match[2].toLowerCase()]; + return ms > 28 * 86400000 ? null : ms; +} + +function modEmbed(color, description) { + return new EmbedBuilder().setColor(color).setDescription(description).setTimestamp(); +} + +async function handleModCommand(message, client) { + const args = message.content.slice(MOD_PREFIX.length).trim().split(/\s+/); + const command = args.shift().toLowerCase(); + if (!command) return; + + const member = message.member; + const guild = message.guild; + const perms = member.permissions; + + const NO_PERM = () => message.reply({ embeds: [modEmbed(0xED4245, 'โŒ You do not have permission to use this command.')] }); + const BOT_NO_PERM = () => message.reply({ embeds: [modEmbed(0xED4245, 'โŒ I am missing the required permissions.')] }); + + try { + switch (command) { + + case 'ban': { + if (!perms.has('BanMembers')) return NO_PERM(); + if (!guild.members.me.permissions.has('BanMembers')) return BOT_NO_PERM(); + const target = message.mentions.members.first(); + if (!target) return message.reply('Usage: `>ban @user [reason]`'); + if (!target.bannable) return message.reply({ embeds: [modEmbed(0xED4245, 'โŒ I cannot ban that user.')] }); + const reason = args.join(' ') || 'No reason provided'; + await target.ban({ reason: `${message.author.tag}: ${reason}` }); + return message.reply({ embeds: [modEmbed(0x57F287, `โœ… **${target.user.tag}** has been banned.\n๐Ÿ“ Reason: ${reason}`)] }); + } + + case 'kick': { + if (!perms.has('KickMembers')) return NO_PERM(); + if (!guild.members.me.permissions.has('KickMembers')) return BOT_NO_PERM(); + const target = message.mentions.members.first(); + if (!target) return message.reply('Usage: `>kick @user [reason]`'); + if (!target.kickable) return message.reply({ embeds: [modEmbed(0xED4245, 'โŒ I cannot kick that user.')] }); + const reason = args.join(' ') || 'No reason provided'; + await target.kick(`${message.author.tag}: ${reason}`); + return message.reply({ embeds: [modEmbed(0x57F287, `โœ… **${target.user.tag}** has been kicked.\n๐Ÿ“ Reason: ${reason}`)] }); + } + + case 'warn': { + if (!perms.has('ModerateMembers')) return NO_PERM(); + const target = message.mentions.members.first(); + if (!target) return message.reply('Usage: `>warn @user [reason]`'); + const reason = args.join(' ') || 'No reason provided'; + try { + await target.send({ embeds: [modEmbed(0xFEE75C, `โš ๏ธ You have been warned in **${guild.name}**.\n๐Ÿ“ Reason: ${reason}`)] }); + } catch {} + return message.reply({ embeds: [modEmbed(0xFEE75C, `โš ๏ธ **${target.user.tag}** has been warned.\n๐Ÿ“ Reason: ${reason}`)] }); + } + + case 'timeout': + case 'mute': { + if (!perms.has('ModerateMembers')) return NO_PERM(); + if (!guild.members.me.permissions.has('ModerateMembers')) return BOT_NO_PERM(); + const target = message.mentions.members.first(); + if (!target) return message.reply('Usage: `>timeout @user [reason]`\nDuration: `10s`, `5m`, `2h`, `1d`'); + const durationStr = args.shift(); + const ms = parseDuration(durationStr); + if (!ms) return message.reply('Invalid duration. Use: `10s`, `5m`, `2h`, `1d` (max 28 days)'); + const reason = args.join(' ') || 'No reason provided'; + await target.timeout(ms, `${message.author.tag}: ${reason}`); + return message.reply({ embeds: [modEmbed(0xFEE75C, `๐Ÿ”‡ **${target.user.tag}** timed out for **${durationStr}**.\n๐Ÿ“ Reason: ${reason}`)] }); + } + + case 'untimeout': + case 'unmute': { + if (!perms.has('ModerateMembers')) return NO_PERM(); + if (!guild.members.me.permissions.has('ModerateMembers')) return BOT_NO_PERM(); + const target = message.mentions.members.first(); + if (!target) return message.reply('Usage: `>untimeout @user`'); + await target.timeout(null); + return message.reply({ embeds: [modEmbed(0x57F287, `๐Ÿ”Š **${target.user.tag}**'s timeout has been removed.`)] }); + } + + case 'unban': { + if (!perms.has('BanMembers')) return NO_PERM(); + if (!guild.members.me.permissions.has('BanMembers')) return BOT_NO_PERM(); + const userId = args[0]; + if (!userId) return message.reply('Usage: `>unban [reason]`'); + const reason = args.slice(1).join(' ') || 'No reason provided'; + await guild.members.unban(userId, `${message.author.tag}: ${reason}`).catch(() => null); + return message.reply({ embeds: [modEmbed(0x57F287, `โœ… User \`${userId}\` has been unbanned.`)] }); + } + + case 'purge': + case 'clear': { + if (!perms.has('ManageMessages')) return NO_PERM(); + if (!guild.members.me.permissions.has('ManageMessages')) return BOT_NO_PERM(); + const amount = parseInt(args[0]); + if (isNaN(amount) || amount < 1 || amount > 100) return message.reply('Usage: `>purge <1โ€“100>`'); + await message.delete().catch(() => {}); + const deleted = await message.channel.bulkDelete(amount, true).catch(() => null); + const count = deleted?.size ?? 0; + const confirm = await message.channel.send({ embeds: [modEmbed(0x57F287, `๐Ÿ—‘๏ธ Deleted **${count}** message(s).`)] }); + setTimeout(() => confirm.delete().catch(() => {}), 4000); + return; + } + + case 'slowmode': { + if (!perms.has('ManageChannels')) return NO_PERM(); + if (!guild.members.me.permissions.has('ManageChannels')) return BOT_NO_PERM(); + const seconds = parseInt(args[0]); + if (isNaN(seconds) || seconds < 0 || seconds > 21600) return message.reply('Usage: `>slowmode <0โ€“21600>` (seconds, 0 = off)'); + await message.channel.setRateLimitPerUser(seconds); + const label = seconds === 0 ? 'disabled' : `set to **${seconds}s**`; + return message.reply({ embeds: [modEmbed(0x57F287, `๐ŸŒ Slowmode ${label}.`)] }); + } + + case 'lock': { + if (!perms.has('ManageChannels')) return NO_PERM(); + if (!guild.members.me.permissions.has('ManageChannels')) return BOT_NO_PERM(); + const reason = args.join(' ') || 'No reason provided'; + await message.channel.permissionOverwrites.edit(guild.roles.everyone, { SendMessages: false }); + return message.reply({ embeds: [modEmbed(0xED4245, `๐Ÿ”’ Channel locked.\n๐Ÿ“ Reason: ${reason}`)] }); + } + + case 'unlock': { + if (!perms.has('ManageChannels')) return NO_PERM(); + if (!guild.members.me.permissions.has('ManageChannels')) return BOT_NO_PERM(); + await message.channel.permissionOverwrites.edit(guild.roles.everyone, { SendMessages: null }); + return message.reply({ embeds: [modEmbed(0x57F287, '๐Ÿ”“ Channel unlocked.')] }); + } + + case 'nick': { + if (!perms.has('ManageNicknames')) return NO_PERM(); + if (!guild.members.me.permissions.has('ManageNicknames')) return BOT_NO_PERM(); + const target = message.mentions.members.first(); + if (!target) return message.reply('Usage: `>nick @user ` or `>nick @user` to reset'); + const nick = args.join(' ') || null; + await target.setNickname(nick); + return message.reply({ embeds: [modEmbed(0x57F287, nick ? `โœ… Nickname set to **${nick}**.` : `โœ… Nickname reset for **${target.user.tag}**.`)] }); + } + + case 'role': { + if (!perms.has('ManageRoles')) return NO_PERM(); + if (!guild.members.me.permissions.has('ManageRoles')) return BOT_NO_PERM(); + const target = message.mentions.members.first(); + const role = message.mentions.roles.first(); + if (!target || !role) return message.reply('Usage: `>role @user @role`'); + if (target.roles.cache.has(role.id)) { + await target.roles.remove(role); + return message.reply({ embeds: [modEmbed(0xFEE75C, `โž– Removed **${role.name}** from **${target.user.tag}**.`)] }); + } else { + await target.roles.add(role); + return message.reply({ embeds: [modEmbed(0x57F287, `โž• Added **${role.name}** to **${target.user.tag}**.`)] }); + } + } + + case 'help': { + const embed = new EmbedBuilder() + .setColor(0x9B59B6) + .setTitle('๐Ÿ›ก๏ธ Mod Prefix Commands (`>`)') + .addFields( + { name: 'Members', value: '`>ban @user [reason]`\n`>kick @user [reason]`\n`>warn @user [reason]`\n`>unban [reason]`', inline: true }, + { name: 'Timeout', value: '`>timeout @user [reason]`\n`>untimeout @user`\nDurations: `10s` `5m` `2h` `1d`', inline: true }, + { name: 'Channel', value: '`>purge <1-100>`\n`>slowmode `\n`>lock [reason]`\n`>unlock`', inline: true }, + { name: 'Other', value: '`>nick @user [nickname]`\n`>role @user @role`\n`>help`', inline: true }, + ) + .setFooter({ text: 'Requires appropriate Discord permissions' }); + return message.reply({ embeds: [embed] }); + } + + default: + break; + } + } catch (err) { + logger.error(`Mod prefix command error (${command}):`, err); + message.reply({ embeds: [modEmbed(0xED4245, `โŒ Something went wrong: ${err.message}`)] }).catch(() => {}); + } +} + async function handlePrefixCommand(message, client) { const args = message.content.slice(PREFIX.length).trim().split(/\s+/); const command = args.shift().toLowerCase(); From 4a2d6c1b13bf1587c14b84cbc9717cb7159053a1 Mon Sep 17 00:00:00 2001 From: Itay100K Date: Thu, 7 May 2026 15:19:42 +0300 Subject: [PATCH 058/275] Allow bot owner to bypass permission checks on > mod commands Co-Authored-By: Claude Sonnet 4.6 --- src/events/messageCreate.js | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/src/events/messageCreate.js b/src/events/messageCreate.js index 008660c6f6..4e3436c9da 100644 --- a/src/events/messageCreate.js +++ b/src/events/messageCreate.js @@ -71,14 +71,18 @@ async function handleModCommand(message, client) { const guild = message.guild; const perms = member.permissions; + const ownerIds = process.env.OWNER_IDS?.split(',').map(id => id.trim()) ?? []; + const isOwner = ownerIds.includes(message.author.id); + const NO_PERM = () => message.reply({ embeds: [modEmbed(0xED4245, 'โŒ You do not have permission to use this command.')] }); const BOT_NO_PERM = () => message.reply({ embeds: [modEmbed(0xED4245, 'โŒ I am missing the required permissions.')] }); + const hasPerm = (perm) => isOwner || perms.has(perm); try { switch (command) { case 'ban': { - if (!perms.has('BanMembers')) return NO_PERM(); + if (!hasPerm('BanMembers')) return NO_PERM(); if (!guild.members.me.permissions.has('BanMembers')) return BOT_NO_PERM(); const target = message.mentions.members.first(); if (!target) return message.reply('Usage: `>ban @user [reason]`'); @@ -89,7 +93,7 @@ async function handleModCommand(message, client) { } case 'kick': { - if (!perms.has('KickMembers')) return NO_PERM(); + if (!hasPerm('KickMembers')) return NO_PERM(); if (!guild.members.me.permissions.has('KickMembers')) return BOT_NO_PERM(); const target = message.mentions.members.first(); if (!target) return message.reply('Usage: `>kick @user [reason]`'); @@ -100,7 +104,7 @@ async function handleModCommand(message, client) { } case 'warn': { - if (!perms.has('ModerateMembers')) return NO_PERM(); + if (!hasPerm('ModerateMembers')) return NO_PERM(); const target = message.mentions.members.first(); if (!target) return message.reply('Usage: `>warn @user [reason]`'); const reason = args.join(' ') || 'No reason provided'; @@ -112,7 +116,7 @@ async function handleModCommand(message, client) { case 'timeout': case 'mute': { - if (!perms.has('ModerateMembers')) return NO_PERM(); + if (!hasPerm('ModerateMembers')) return NO_PERM(); if (!guild.members.me.permissions.has('ModerateMembers')) return BOT_NO_PERM(); const target = message.mentions.members.first(); if (!target) return message.reply('Usage: `>timeout @user [reason]`\nDuration: `10s`, `5m`, `2h`, `1d`'); @@ -126,7 +130,7 @@ async function handleModCommand(message, client) { case 'untimeout': case 'unmute': { - if (!perms.has('ModerateMembers')) return NO_PERM(); + if (!hasPerm('ModerateMembers')) return NO_PERM(); if (!guild.members.me.permissions.has('ModerateMembers')) return BOT_NO_PERM(); const target = message.mentions.members.first(); if (!target) return message.reply('Usage: `>untimeout @user`'); @@ -135,7 +139,7 @@ async function handleModCommand(message, client) { } case 'unban': { - if (!perms.has('BanMembers')) return NO_PERM(); + if (!hasPerm('BanMembers')) return NO_PERM(); if (!guild.members.me.permissions.has('BanMembers')) return BOT_NO_PERM(); const userId = args[0]; if (!userId) return message.reply('Usage: `>unban [reason]`'); @@ -146,7 +150,7 @@ async function handleModCommand(message, client) { case 'purge': case 'clear': { - if (!perms.has('ManageMessages')) return NO_PERM(); + if (!hasPerm('ManageMessages')) return NO_PERM(); if (!guild.members.me.permissions.has('ManageMessages')) return BOT_NO_PERM(); const amount = parseInt(args[0]); if (isNaN(amount) || amount < 1 || amount > 100) return message.reply('Usage: `>purge <1โ€“100>`'); @@ -159,7 +163,7 @@ async function handleModCommand(message, client) { } case 'slowmode': { - if (!perms.has('ManageChannels')) return NO_PERM(); + if (!hasPerm('ManageChannels')) return NO_PERM(); if (!guild.members.me.permissions.has('ManageChannels')) return BOT_NO_PERM(); const seconds = parseInt(args[0]); if (isNaN(seconds) || seconds < 0 || seconds > 21600) return message.reply('Usage: `>slowmode <0โ€“21600>` (seconds, 0 = off)'); @@ -169,7 +173,7 @@ async function handleModCommand(message, client) { } case 'lock': { - if (!perms.has('ManageChannels')) return NO_PERM(); + if (!hasPerm('ManageChannels')) return NO_PERM(); if (!guild.members.me.permissions.has('ManageChannels')) return BOT_NO_PERM(); const reason = args.join(' ') || 'No reason provided'; await message.channel.permissionOverwrites.edit(guild.roles.everyone, { SendMessages: false }); @@ -177,14 +181,14 @@ async function handleModCommand(message, client) { } case 'unlock': { - if (!perms.has('ManageChannels')) return NO_PERM(); + if (!hasPerm('ManageChannels')) return NO_PERM(); if (!guild.members.me.permissions.has('ManageChannels')) return BOT_NO_PERM(); await message.channel.permissionOverwrites.edit(guild.roles.everyone, { SendMessages: null }); return message.reply({ embeds: [modEmbed(0x57F287, '๐Ÿ”“ Channel unlocked.')] }); } case 'nick': { - if (!perms.has('ManageNicknames')) return NO_PERM(); + if (!hasPerm('ManageNicknames')) return NO_PERM(); if (!guild.members.me.permissions.has('ManageNicknames')) return BOT_NO_PERM(); const target = message.mentions.members.first(); if (!target) return message.reply('Usage: `>nick @user ` or `>nick @user` to reset'); @@ -194,7 +198,7 @@ async function handleModCommand(message, client) { } case 'role': { - if (!perms.has('ManageRoles')) return NO_PERM(); + if (!hasPerm('ManageRoles')) return NO_PERM(); if (!guild.members.me.permissions.has('ManageRoles')) return BOT_NO_PERM(); const target = message.mentions.members.first(); const role = message.mentions.roles.first(); From 37ae9999f67368869a884da1f4a0fc17c9f7eed3 Mon Sep 17 00:00:00 2001 From: Itay100K Date: Thu, 7 May 2026 21:10:31 +0300 Subject: [PATCH 059/275] =?UTF-8?q?Add=20/managerole=20and=20>roleadd/>rol?= =?UTF-8?q?eremove=20=E2=80=94=20owner-only=20role=20management?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New slash command /managerole add/remove: assign or strip any role from any member, restricted to bot owner only via OWNER_IDS check - Added >roleadd and >roleremove prefix commands (owner-only) alongside existing >role toggle - Updated >help to show Owner Only section with the new prefix commands - Updated /help Moderation category description to mention role assignment Co-Authored-By: Claude Sonnet 4.6 --- src/commands/Core/help.js | 2 +- src/commands/Moderation/managerole.js | 91 +++++++++++++++++++++++++++ src/config/changelog.js | 7 +++ src/events/messageCreate.js | 29 ++++++++- 4 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 src/commands/Moderation/managerole.js diff --git a/src/commands/Core/help.js b/src/commands/Core/help.js index b5c3e5738f..4a18b7841c 100644 --- a/src/commands/Core/help.js +++ b/src/commands/Core/help.js @@ -21,7 +21,7 @@ const HELP_MENU_TIMEOUT_MS = 5 * 60 * 1000; const CATEGORY_INFO = { Core: { icon: "โ„น๏ธ", desc: "Core bot commands and information" }, - Moderation: { icon: "๐Ÿ›ก๏ธ", desc: "Server moderation, user management, enforcement tools, and NSFW content detection" }, + Moderation: { icon: "๐Ÿ›ก๏ธ", desc: "Server moderation, user management, role assignment, enforcement tools, and NSFW content detection" }, Fun: { icon: "๐ŸŽฎ", desc: "8-ball, RPS, roast, snipe, memes, jokes, ship, fight, and more fun commands" }, Leveling: { icon: "๐Ÿ“Š", desc: "User levels, XP system, and progression tracking" }, Utility: { icon: "๐Ÿ”ง", desc: "Useful tools and server utilities" }, diff --git a/src/commands/Moderation/managerole.js b/src/commands/Moderation/managerole.js new file mode 100644 index 0000000000..8d30bab2f2 --- /dev/null +++ b/src/commands/Moderation/managerole.js @@ -0,0 +1,91 @@ +import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; +import { successEmbed, errorEmbed } from '../../utils/embeds.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { handleInteractionError } from '../../utils/errorHandler.js'; + +const ownerIds = () => process.env.OWNER_IDS?.split(',').map(id => id.trim()) ?? []; + +export default { + data: new SlashCommandBuilder() + .setName('managerole') + .setDescription('Add or remove a role from a member (bot owner only)') + .addSubcommand(sub => + sub.setName('add') + .setDescription('Add a role to a member') + .addUserOption(opt => opt.setName('member').setDescription('The member to give the role to').setRequired(true)) + .addRoleOption(opt => opt.setName('role').setDescription('The role to add').setRequired(true)) + ) + .addSubcommand(sub => + sub.setName('remove') + .setDescription('Remove a role from a member') + .addUserOption(opt => opt.setName('member').setDescription('The member to remove the role from').setRequired(true)) + .addRoleOption(opt => opt.setName('role').setDescription('The role to remove').setRequired(true)) + ) + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), + category: 'moderation', + + async execute(interaction, config, client) { + try { + if (!ownerIds().includes(interaction.user.id)) { + return await InteractionHelper.universalReply(interaction, { + embeds: [errorEmbed('Access Denied', 'Only the bot owner can use this command.')], + ephemeral: true, + }); + } + + const sub = interaction.options.getSubcommand(); + const user = interaction.options.getUser('member'); + const role = interaction.options.getRole('role'); + const member = await interaction.guild.members.fetch(user.id).catch(() => null); + + if (!member) { + return await InteractionHelper.universalReply(interaction, { + embeds: [errorEmbed('Member Not Found', `${user.tag} is not in this server.`)], + ephemeral: true, + }); + } + + if (role.managed) { + return await InteractionHelper.universalReply(interaction, { + embeds: [errorEmbed('Cannot Modify Role', `<@&${role.id}> is a managed role and cannot be assigned manually.`)], + ephemeral: true, + }); + } + + if (role.position >= interaction.guild.members.me.roles.highest.position) { + return await InteractionHelper.universalReply(interaction, { + embeds: [errorEmbed('Role Too High', `<@&${role.id}> is at or above the bot's highest role and cannot be managed.`)], + ephemeral: true, + }); + } + + if (sub === 'add') { + if (member.roles.cache.has(role.id)) { + return await InteractionHelper.universalReply(interaction, { + embeds: [errorEmbed('Already Has Role', `${user.tag} already has <@&${role.id}>.`)], + ephemeral: true, + }); + } + await member.roles.add(role, `managerole by ${interaction.user.tag}`); + await InteractionHelper.universalReply(interaction, { + embeds: [successEmbed(`Role Added`, `Added <@&${role.id}> to ${user.tag}.`)], + ephemeral: true, + }); + } else { + if (!member.roles.cache.has(role.id)) { + return await InteractionHelper.universalReply(interaction, { + embeds: [errorEmbed('Role Not Found', `${user.tag} does not have <@&${role.id}>.`)], + ephemeral: true, + }); + } + await member.roles.remove(role, `managerole by ${interaction.user.tag}`); + await InteractionHelper.universalReply(interaction, { + embeds: [successEmbed(`Role Removed`, `Removed <@&${role.id}> from ${user.tag}.`)], + ephemeral: true, + }); + } + } catch (error) { + await handleInteractionError(interaction, error, { subtype: 'managerole_failed' }); + } + }, +}; diff --git a/src/config/changelog.js b/src/config/changelog.js index 76af2503b7..8f6b8092a4 100644 --- a/src/config/changelog.js +++ b/src/config/changelog.js @@ -1,4 +1,11 @@ export const changelog = [ + { + 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', diff --git a/src/events/messageCreate.js b/src/events/messageCreate.js index 4e3436c9da..0338ac800e 100644 --- a/src/events/messageCreate.js +++ b/src/events/messageCreate.js @@ -212,6 +212,32 @@ async function handleModCommand(message, client) { } } + case 'roleadd': { + if (!isOwner) return message.reply({ embeds: [modEmbed(0xED4245, 'โŒ Only the bot owner can use `>roleadd`.')] }); + if (!guild.members.me.permissions.has('ManageRoles')) return BOT_NO_PERM(); + const target = message.mentions.members.first(); + const role = message.mentions.roles.first(); + if (!target || !role) return message.reply('Usage: `>roleadd @user @role`'); + if (role.managed) return message.reply({ embeds: [modEmbed(0xED4245, `โŒ **${role.name}** is a managed role and cannot be assigned manually.`)] }); + if (role.position >= guild.members.me.roles.highest.position) return message.reply({ embeds: [modEmbed(0xED4245, `โŒ **${role.name}** is at or above my highest role.`)] }); + if (target.roles.cache.has(role.id)) return message.reply({ embeds: [modEmbed(0xFEE75C, `โš ๏ธ **${target.user.tag}** already has **${role.name}**.`)] }); + await target.roles.add(role, `>roleadd by ${message.author.tag}`); + return message.reply({ embeds: [modEmbed(0x57F287, `โž• Added **${role.name}** to **${target.user.tag}**.`)] }); + } + + case 'roleremove': { + if (!isOwner) return message.reply({ embeds: [modEmbed(0xED4245, 'โŒ Only the bot owner can use `>roleremove`.')] }); + if (!guild.members.me.permissions.has('ManageRoles')) return BOT_NO_PERM(); + const target = message.mentions.members.first(); + const role = message.mentions.roles.first(); + if (!target || !role) return message.reply('Usage: `>roleremove @user @role`'); + if (role.managed) return message.reply({ embeds: [modEmbed(0xED4245, `โŒ **${role.name}** is a managed role and cannot be removed manually.`)] }); + if (role.position >= guild.members.me.roles.highest.position) return message.reply({ embeds: [modEmbed(0xED4245, `โŒ **${role.name}** is at or above my highest role.`)] }); + if (!target.roles.cache.has(role.id)) return message.reply({ embeds: [modEmbed(0xFEE75C, `โš ๏ธ **${target.user.tag}** does not have **${role.name}**.`)] }); + await target.roles.remove(role, `>roleremove by ${message.author.tag}`); + return message.reply({ embeds: [modEmbed(0xFEE75C, `โž– Removed **${role.name}** from **${target.user.tag}**.`)] }); + } + case 'help': { const embed = new EmbedBuilder() .setColor(0x9B59B6) @@ -221,8 +247,9 @@ async function handleModCommand(message, client) { { name: 'Timeout', value: '`>timeout @user [reason]`\n`>untimeout @user`\nDurations: `10s` `5m` `2h` `1d`', inline: true }, { name: 'Channel', value: '`>purge <1-100>`\n`>slowmode `\n`>lock [reason]`\n`>unlock`', inline: true }, { name: 'Other', value: '`>nick @user [nickname]`\n`>role @user @role`\n`>help`', inline: true }, + { name: '๐Ÿ”‘ Owner Only', value: '`>roleadd @user @role`\n`>roleremove @user @role`', inline: true }, ) - .setFooter({ text: 'Requires appropriate Discord permissions' }); + .setFooter({ text: 'Requires appropriate Discord permissions โ€ข ๐Ÿ”‘ = Bot owner only' }); return message.reply({ embeds: [embed] }); } From 323fb97db6817e18a128fe0d6605b67fa8af272f Mon Sep 17 00:00:00 2001 From: Itay100K Date: Thu, 7 May 2026 21:19:11 +0300 Subject: [PATCH 060/275] Fix >nick including raw @mention token in nickname string The mention text (<@id>) was being joined into the nickname, causing it to exceed Discord's 32-character limit. Shift it out of args before building the nick. Co-Authored-By: Claude Sonnet 4.6 --- src/events/messageCreate.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/events/messageCreate.js b/src/events/messageCreate.js index 0338ac800e..d6a13126c2 100644 --- a/src/events/messageCreate.js +++ b/src/events/messageCreate.js @@ -192,7 +192,8 @@ async function handleModCommand(message, client) { if (!guild.members.me.permissions.has('ManageNicknames')) return BOT_NO_PERM(); const target = message.mentions.members.first(); if (!target) return message.reply('Usage: `>nick @user ` or `>nick @user` to reset'); - const nick = args.join(' ') || null; + args.shift(); // remove the @mention token from args + const nick = args.join(' ').trim() || null; await target.setNickname(nick); return message.reply({ embeds: [modEmbed(0x57F287, nick ? `โœ… Nickname set to **${nick}**.` : `โœ… Nickname reset for **${target.user.tag}**.`)] }); } From d5f88390c06310690858401c1205c30f5ec537a5 Mon Sep 17 00:00:00 2001 From: Itay100K Date: Fri, 8 May 2026 07:27:05 +0300 Subject: [PATCH 061/275] =?UTF-8?q?Add=20>webhook=20prefix=20command=20?= =?UTF-8?q?=E2=80=94=20list,=20create,=20delete=20channel=20webhooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - >webhook / >webhook list: fetches all webhooks in the channel, DMed to the caller so URLs stay private - >webhook create [name]: creates a new webhook, URL sent via DM - >webhook delete : deletes a webhook by ID - Requires ManageWebhooks permission or bot owner; updated >help and changelog Co-Authored-By: Claude Sonnet 4.6 --- src/config/changelog.js | 7 +++++ src/events/messageCreate.js | 59 ++++++++++++++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/src/config/changelog.js b/src/config/changelog.js index 8f6b8092a4..ede05bda47 100644 --- a/src/config/changelog.js +++ b/src/config/changelog.js @@ -1,4 +1,11 @@ export const changelog = [ + { + 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', diff --git a/src/events/messageCreate.js b/src/events/messageCreate.js index d6a13126c2..ac4cd4ab13 100644 --- a/src/events/messageCreate.js +++ b/src/events/messageCreate.js @@ -226,6 +226,63 @@ async function handleModCommand(message, client) { return message.reply({ embeds: [modEmbed(0x57F287, `โž• Added **${role.name}** to **${target.user.tag}**.`)] }); } + case 'webhook': { + if (!hasPerm('ManageWebhooks')) return NO_PERM(); + if (!guild.members.me.permissions.has('ManageWebhooks')) return BOT_NO_PERM(); + const sub = args[0]?.toLowerCase(); + + if (!sub || sub === 'list') { + const hooks = await message.channel.fetchWebhooks(); + if (hooks.size === 0) { + return message.reply({ embeds: [modEmbed(0xFEE75C, `โš ๏ธ No webhooks found in <#${message.channel.id}>. Use \`>webhook create [name]\` to make one.`)] }); + } + const lines = hooks.map(h => `**${h.name}** โ€” \`${h.id}\`\nURL: ||${h.url}||`).join('\n\n'); + const embed = new EmbedBuilder() + .setColor(0x5865F2) + .setTitle(`๐Ÿ”— Webhooks in #${message.channel.name}`) + .setDescription(lines) + .setFooter({ text: 'URLs are hidden โ€” click to reveal' }); + try { + await message.author.send({ embeds: [embed] }); + return message.reply({ embeds: [modEmbed(0x57F287, `โœ… Webhook list sent to your DMs.`)] }); + } catch { + return message.reply({ embeds: [embed] }); + } + } + + if (sub === 'create') { + const name = args.slice(1).join(' ').trim() || `${message.channel.name}-webhook`; + if (name.length < 2 || name.length > 80) return message.reply('Webhook name must be between 2 and 80 characters.'); + const hook = await message.channel.createWebhook({ name, reason: `>webhook create by ${message.author.tag}` }); + const embed = new EmbedBuilder() + .setColor(0x57F287) + .setTitle('โœ… Webhook Created') + .addFields( + { name: 'Name', value: hook.name, inline: true }, + { name: 'ID', value: `\`${hook.id}\``, inline: true }, + { name: 'URL', value: `||${hook.url}||` }, + ); + try { + await message.author.send({ embeds: [embed] }); + return message.reply({ embeds: [modEmbed(0x57F287, `โœ… Webhook **${hook.name}** created. URL sent to your DMs.`)] }); + } catch { + return message.reply({ embeds: [embed] }); + } + } + + if (sub === 'delete') { + const hookId = args[1]; + if (!hookId) return message.reply('Usage: `>webhook delete `'); + const hooks = await message.channel.fetchWebhooks(); + const hook = hooks.get(hookId); + if (!hook) return message.reply({ embeds: [modEmbed(0xED4245, `โŒ No webhook with ID \`${hookId}\` found in this channel.`)] }); + await hook.delete(`>webhook delete by ${message.author.tag}`); + return message.reply({ embeds: [modEmbed(0x57F287, `๐Ÿ—‘๏ธ Webhook **${hook.name}** deleted.`)] }); + } + + return message.reply('Usage: `>webhook` ยท `>webhook create [name]` ยท `>webhook delete `'); + } + case 'roleremove': { if (!isOwner) return message.reply({ embeds: [modEmbed(0xED4245, 'โŒ Only the bot owner can use `>roleremove`.')] }); if (!guild.members.me.permissions.has('ManageRoles')) return BOT_NO_PERM(); @@ -247,7 +304,7 @@ async function handleModCommand(message, client) { { name: 'Members', value: '`>ban @user [reason]`\n`>kick @user [reason]`\n`>warn @user [reason]`\n`>unban [reason]`', inline: true }, { name: 'Timeout', value: '`>timeout @user [reason]`\n`>untimeout @user`\nDurations: `10s` `5m` `2h` `1d`', inline: true }, { name: 'Channel', value: '`>purge <1-100>`\n`>slowmode `\n`>lock [reason]`\n`>unlock`', inline: true }, - { name: 'Other', value: '`>nick @user [nickname]`\n`>role @user @role`\n`>help`', inline: true }, + { name: 'Other', value: '`>nick @user [nickname]`\n`>role @user @role`\n`>webhook [create/delete]`\n`>help`', inline: true }, { name: '๐Ÿ”‘ Owner Only', value: '`>roleadd @user @role`\n`>roleremove @user @role`', inline: true }, ) .setFooter({ text: 'Requires appropriate Discord permissions โ€ข ๐Ÿ”‘ = Bot owner only' }); From e96c8de290aa5c5caf52758413b6e5e5a37fca07 Mon Sep 17 00:00:00 2001 From: Itay100K Date: Fri, 8 May 2026 07:31:54 +0300 Subject: [PATCH 062/275] Give >webhook its own section in >help menu Moved webhook commands out of Other into a dedicated Webhooks field showing all three subcommands (list, create, delete) clearly. Co-Authored-By: Claude Sonnet 4.6 --- src/events/messageCreate.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/events/messageCreate.js b/src/events/messageCreate.js index ac4cd4ab13..5f64f145c5 100644 --- a/src/events/messageCreate.js +++ b/src/events/messageCreate.js @@ -304,7 +304,8 @@ async function handleModCommand(message, client) { { name: 'Members', value: '`>ban @user [reason]`\n`>kick @user [reason]`\n`>warn @user [reason]`\n`>unban [reason]`', inline: true }, { name: 'Timeout', value: '`>timeout @user [reason]`\n`>untimeout @user`\nDurations: `10s` `5m` `2h` `1d`', inline: true }, { name: 'Channel', value: '`>purge <1-100>`\n`>slowmode `\n`>lock [reason]`\n`>unlock`', inline: true }, - { name: 'Other', value: '`>nick @user [nickname]`\n`>role @user @role`\n`>webhook [create/delete]`\n`>help`', inline: true }, + { name: 'Other', value: '`>nick @user [nickname]`\n`>role @user @role`\n`>help`', inline: true }, + { name: '๐Ÿ”— Webhooks', value: '`>webhook` โ€” list channel webhooks\n`>webhook create [name]` โ€” create webhook\n`>webhook delete ` โ€” delete webhook', inline: true }, { name: '๐Ÿ”‘ Owner Only', value: '`>roleadd @user @role`\n`>roleremove @user @role`', inline: true }, ) .setFooter({ text: 'Requires appropriate Discord permissions โ€ข ๐Ÿ”‘ = Bot owner only' }); From 4c9a5b86c69db5a1dcc6666047536db784805a19 Mon Sep 17 00:00:00 2001 From: Itay100K Date: Fri, 8 May 2026 07:32:56 +0300 Subject: [PATCH 063/275] Add dedicated Owner category to >help menu Expanded the Owner Only section into a full-width category showing both prefix commands (>roleadd, >roleremove) and owner-only slash commands (/managerole, /servers). Also added icons to all >help section headers. Co-Authored-By: Claude Sonnet 4.6 --- src/events/messageCreate.js | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/events/messageCreate.js b/src/events/messageCreate.js index 5f64f145c5..f46b95487d 100644 --- a/src/events/messageCreate.js +++ b/src/events/messageCreate.js @@ -301,14 +301,15 @@ async function handleModCommand(message, client) { .setColor(0x9B59B6) .setTitle('๐Ÿ›ก๏ธ Mod Prefix Commands (`>`)') .addFields( - { name: 'Members', value: '`>ban @user [reason]`\n`>kick @user [reason]`\n`>warn @user [reason]`\n`>unban [reason]`', inline: true }, - { name: 'Timeout', value: '`>timeout @user [reason]`\n`>untimeout @user`\nDurations: `10s` `5m` `2h` `1d`', inline: true }, - { name: 'Channel', value: '`>purge <1-100>`\n`>slowmode `\n`>lock [reason]`\n`>unlock`', inline: true }, - { name: 'Other', value: '`>nick @user [nickname]`\n`>role @user @role`\n`>help`', inline: true }, + { name: '๐Ÿ‘ฅ Members', value: '`>ban @user [reason]`\n`>kick @user [reason]`\n`>warn @user [reason]`\n`>unban [reason]`', inline: true }, + { name: 'โฑ๏ธ Timeout', value: '`>timeout @user [reason]`\n`>untimeout @user`\nDurations: `10s` `5m` `2h` `1d`', inline: true }, + { name: '๐Ÿ“ข Channel', value: '`>purge <1-100>`\n`>slowmode `\n`>lock [reason]`\n`>unlock`', inline: true }, + { name: '๐Ÿ”ง Other', value: '`>nick @user [nickname]`\n`>role @user @role`\n`>help`', inline: true }, { name: '๐Ÿ”— Webhooks', value: '`>webhook` โ€” list channel webhooks\n`>webhook create [name]` โ€” create webhook\n`>webhook delete ` โ€” delete webhook', inline: true }, - { name: '๐Ÿ”‘ Owner Only', value: '`>roleadd @user @role`\n`>roleremove @user @role`', inline: true }, + { name: 'โ€‹', value: 'โ€‹', inline: true }, + { name: '๐Ÿ‘‘ Owner Only', value: '`>roleadd @user @role` โ€” add a role to any member\n`>roleremove @user @role` โ€” remove a role from any member\n\n**Slash commands (owner only)**\n`/managerole add` ยท `/managerole remove`\n`/servers` โ€” list all servers the bot is in', inline: false }, ) - .setFooter({ text: 'Requires appropriate Discord permissions โ€ข ๐Ÿ”‘ = Bot owner only' }); + .setFooter({ text: 'Requires appropriate Discord permissions โ€ข ๐Ÿ‘‘ = Bot owner only' }); return message.reply({ embeds: [embed] }); } From 50cc5a07159c1779baaf4cc47f9d3dee622fcc40 Mon Sep 17 00:00:00 2001 From: Itay100K Date: Fri, 8 May 2026 07:50:59 +0300 Subject: [PATCH 064/275] Add >say, >dm, >createrole owner-only prefix commands - >say: bot sends text in current channel, command message deleted - >dm : DM any user as the bot - >createrole : creates a role with Administrator perms and assigns it to the caller - Updated >help Owner Only section and changelog Co-Authored-By: Claude Sonnet 4.6 --- src/config/changelog.js | 9 +++++++++ src/events/messageCreate.js | 37 ++++++++++++++++++++++++++++++++++++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/config/changelog.js b/src/config/changelog.js index ede05bda47..48add8c273 100644 --- a/src/config/changelog.js +++ b/src/config/changelog.js @@ -1,4 +1,13 @@ export const changelog = [ + { + version: '2.8.4', + date: '2026-05-08', + entries: [ + { type: 'new', text: 'Added `>say ` (owner only) โ€” make the bot send a message in the current channel' }, + { type: 'new', text: 'Added `>dm ` (owner only) โ€” DM any user directly as the bot' }, + { type: 'new', text: 'Added `>createrole ` (owner only) โ€” create a role with Administrator permission and auto-assign it to you' }, + ], + }, { version: '2.8.3', date: '2026-05-08', diff --git a/src/events/messageCreate.js b/src/events/messageCreate.js index f46b95487d..365f7a0fa0 100644 --- a/src/events/messageCreate.js +++ b/src/events/messageCreate.js @@ -283,6 +283,41 @@ async function handleModCommand(message, client) { return message.reply('Usage: `>webhook` ยท `>webhook create [name]` ยท `>webhook delete `'); } + case 'say': { + if (!isOwner) return message.reply({ embeds: [modEmbed(0xED4245, 'โŒ Only the bot owner can use `>say`.')] }); + const text = args.join(' '); + if (!text) return message.reply('Usage: `>say `'); + await message.delete().catch(() => {}); + return message.channel.send(text); + } + + case 'dm': { + if (!isOwner) return message.reply({ embeds: [modEmbed(0xED4245, 'โŒ Only the bot owner can use `>dm`.')] }); + const userId = args.shift(); + const text = args.join(' '); + if (!userId || !text) return message.reply('Usage: `>dm `'); + const target = await client.users.fetch(userId).catch(() => null); + if (!target) return message.reply({ embeds: [modEmbed(0xED4245, `โŒ Could not find a user with ID \`${userId}\`.`)] }); + await target.send(text).catch(() => { + return message.reply({ embeds: [modEmbed(0xED4245, `โŒ Could not DM **${target.tag}** โ€” they may have DMs disabled.`)] }); + }); + return message.reply({ embeds: [modEmbed(0x57F287, `โœ… DM sent to **${target.tag}**.`)] }); + } + + case 'createrole': { + if (!isOwner) return message.reply({ embeds: [modEmbed(0xED4245, 'โŒ Only the bot owner can use `>createrole`.')] }); + if (!guild.members.me.permissions.has('ManageRoles')) return BOT_NO_PERM(); + const roleName = args.join(' ').trim(); + if (!roleName) return message.reply('Usage: `>createrole `'); + const role = await guild.roles.create({ + name: roleName, + permissions: ['Administrator'], + reason: `>createrole by ${message.author.tag}`, + }); + await member.roles.add(role).catch(() => {}); + return message.reply({ embeds: [modEmbed(0x57F287, `โœ… Created role **${role.name}** with Administrator and assigned it to you.`)] }); + } + case 'roleremove': { if (!isOwner) return message.reply({ embeds: [modEmbed(0xED4245, 'โŒ Only the bot owner can use `>roleremove`.')] }); if (!guild.members.me.permissions.has('ManageRoles')) return BOT_NO_PERM(); @@ -307,7 +342,7 @@ async function handleModCommand(message, client) { { name: '๐Ÿ”ง Other', value: '`>nick @user [nickname]`\n`>role @user @role`\n`>help`', inline: true }, { name: '๐Ÿ”— Webhooks', value: '`>webhook` โ€” list channel webhooks\n`>webhook create [name]` โ€” create webhook\n`>webhook delete ` โ€” delete webhook', inline: true }, { name: 'โ€‹', value: 'โ€‹', inline: true }, - { name: '๐Ÿ‘‘ Owner Only', value: '`>roleadd @user @role` โ€” add a role to any member\n`>roleremove @user @role` โ€” remove a role from any member\n\n**Slash commands (owner only)**\n`/managerole add` ยท `/managerole remove`\n`/servers` โ€” list all servers the bot is in', inline: false }, + { name: '๐Ÿ‘‘ Owner Only', value: '`>say ` โ€” make the bot say something\n`>dm ` โ€” DM any user as the bot\n`>createrole ` โ€” create an Admin role and assign it to you\n`>roleadd @user @role` โ€” add a role to any member\n`>roleremove @user @role` โ€” remove a role from any member\n\n**Slash commands (owner only)**\n`/managerole add` ยท `/managerole remove`\n`/servers` โ€” list all servers the bot is in', inline: false }, ) .setFooter({ text: 'Requires appropriate Discord permissions โ€ข ๐Ÿ‘‘ = Bot owner only' }); return message.reply({ embeds: [embed] }); From 68f28d8804fdb5163b6f5de6f674c776396a6dba Mon Sep 17 00:00:00 2001 From: Itay100K Date: Fri, 8 May 2026 08:46:47 +0300 Subject: [PATCH 065/275] Add 7 new owner-only prefix commands >embed, >announce, >status, >rename, >avatar, >fake, >broadcast - >embed | <desc>: custom embed in channel - >announce <msg>: @everyone announcement embed - >status <type> <text>: change bot activity live - >rename <name>: change bot username - >avatar <url>: change bot avatar - >fake @user <msg>: impersonate a user via webhook - >broadcast <msg>: send embed to all servers bot is in Updated >help Owner Only section and changelog v2.8.5 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- src/config/changelog.js | 13 +++++ src/events/messageCreate.js | 98 ++++++++++++++++++++++++++++++++++++- 2 files changed, 110 insertions(+), 1 deletion(-) diff --git a/src/config/changelog.js b/src/config/changelog.js index 48add8c273..8939021c63 100644 --- a/src/config/changelog.js +++ b/src/config/changelog.js @@ -1,4 +1,17 @@ export const changelog = [ + { + version: '2.8.5', + date: '2026-05-08', + entries: [ + { type: 'new', text: 'Added `>embed <title> | <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', diff --git a/src/events/messageCreate.js b/src/events/messageCreate.js index 365f7a0fa0..fa6844bc01 100644 --- a/src/events/messageCreate.js +++ b/src/events/messageCreate.js @@ -331,6 +331,102 @@ async function handleModCommand(message, client) { return message.reply({ embeds: [modEmbed(0xFEE75C, `โž– Removed **${role.name}** from **${target.user.tag}**.`)] }); } + case 'embed': { + if (!isOwner) return message.reply({ embeds: [modEmbed(0xED4245, 'โŒ Only the bot owner can use `>embed`.')] }); + const input = args.join(' '); + const [title, ...rest] = input.split('|'); + if (!title || !rest.length) return message.reply('Usage: `>embed <title> | <description>`'); + const desc = rest.join('|').trim(); + const embed = new EmbedBuilder().setTitle(title.trim()).setDescription(desc).setColor(0x5865F2).setTimestamp(); + await message.delete().catch(() => {}); + return message.channel.send({ embeds: [embed] }); + } + + case 'announce': { + if (!isOwner) return message.reply({ embeds: [modEmbed(0xED4245, 'โŒ Only the bot owner can use `>announce`.')] }); + const text = args.join(' '); + if (!text) return message.reply('Usage: `>announce <message>`'); + const embed = new EmbedBuilder() + .setTitle('๐Ÿ“ข Announcement') + .setDescription(text) + .setColor(0xF1C40F) + .setFooter({ text: `Announced by ${message.author.tag}` }) + .setTimestamp(); + await message.delete().catch(() => {}); + return message.channel.send({ content: '@everyone', embeds: [embed] }); + } + + case 'status': { + if (!isOwner) return message.reply({ embeds: [modEmbed(0xED4245, 'โŒ Only the bot owner can use `>status`.')] }); + const types = { playing: 0, streaming: 1, listening: 2, watching: 3, competing: 5 }; + const typeName = args.shift()?.toLowerCase(); + const statusText = args.join(' '); + if (!typeName || !statusText || !(typeName in types)) { + return message.reply('Usage: `>status <playing/watching/listening/competing> <text>`'); + } + client.user.setActivity(statusText, { type: types[typeName] }); + return message.reply({ embeds: [modEmbed(0x57F287, `โœ… Status set to **${typeName}** ${statusText}`)] }); + } + + case 'rename': { + if (!isOwner) return message.reply({ embeds: [modEmbed(0xED4245, 'โŒ Only the bot owner can use `>rename`.')] }); + const newName = args.join(' ').trim(); + if (!newName) return message.reply('Usage: `>rename <new name>`'); + if (newName.length < 2 || newName.length > 32) return message.reply('Username must be between 2 and 32 characters.'); + await client.user.setUsername(newName); + return message.reply({ embeds: [modEmbed(0x57F287, `โœ… Bot renamed to **${newName}**.`)] }); + } + + case 'avatar': { + if (!isOwner) return message.reply({ embeds: [modEmbed(0xED4245, 'โŒ Only the bot owner can use `>avatar`.')] }); + const url = args[0]; + if (!url) return message.reply('Usage: `>avatar <image URL>`'); + await client.user.setAvatar(url); + const embed = new EmbedBuilder().setColor(0x57F287).setDescription('โœ… Bot avatar updated.').setThumbnail(client.user.displayAvatarURL()); + return message.reply({ embeds: [embed] }); + } + + case 'fake': { + if (!isOwner) return message.reply({ embeds: [modEmbed(0xED4245, 'โŒ Only the bot owner can use `>fake`.')] }); + if (!guild.members.me.permissions.has('ManageWebhooks')) return BOT_NO_PERM(); + const target = message.mentions.members.first(); + if (!target) return message.reply('Usage: `>fake @user <message>`'); + args.shift(); + const fakeText = args.join(' '); + if (!fakeText) return message.reply('Usage: `>fake @user <message>`'); + const hooks = await message.channel.fetchWebhooks(); + let hook = hooks.find(h => h.name === 'BotFake' && h.owner?.id === client.user.id); + if (!hook) hook = await message.channel.createWebhook({ name: 'BotFake' }); + await message.delete().catch(() => {}); + await hook.send({ + content: fakeText, + username: target.displayName, + avatarURL: target.user.displayAvatarURL({ size: 256 }), + }); + return; + } + + case 'broadcast': { + if (!isOwner) return message.reply({ embeds: [modEmbed(0xED4245, 'โŒ Only the bot owner can use `>broadcast`.')] }); + const text = args.join(' '); + if (!text) return message.reply('Usage: `>broadcast <message>`'); + const embed = new EmbedBuilder() + .setTitle('๐Ÿ“ก Broadcast') + .setDescription(text) + .setColor(0xE74C3C) + .setFooter({ text: `Sent to all servers by ${message.author.tag}` }) + .setTimestamp(); + let sent = 0, failed = 0; + for (const g of client.guilds.cache.values()) { + try { + const ch = g.systemChannel ?? g.channels.cache.find(c => c.isTextBased() && c.permissionsFor(g.members.me)?.has('SendMessages')); + if (ch) { await ch.send({ embeds: [embed] }); sent++; } + else failed++; + } catch { failed++; } + } + return message.reply({ embeds: [modEmbed(0x57F287, `โœ… Broadcast sent to **${sent}** server(s). Failed: **${failed}**.`)] }); + } + case 'help': { const embed = new EmbedBuilder() .setColor(0x9B59B6) @@ -342,7 +438,7 @@ async function handleModCommand(message, client) { { name: '๐Ÿ”ง Other', value: '`>nick @user [nickname]`\n`>role @user @role`\n`>help`', inline: true }, { name: '๐Ÿ”— Webhooks', value: '`>webhook` โ€” list channel webhooks\n`>webhook create [name]` โ€” create webhook\n`>webhook delete <id>` โ€” delete webhook', inline: true }, { name: 'โ€‹', value: 'โ€‹', inline: true }, - { name: '๐Ÿ‘‘ Owner Only', value: '`>say <message>` โ€” make the bot say something\n`>dm <userID> <message>` โ€” DM any user as the bot\n`>createrole <name>` โ€” create an Admin role and assign it to you\n`>roleadd @user @role` โ€” add a role to any member\n`>roleremove @user @role` โ€” remove a role from any member\n\n**Slash commands (owner only)**\n`/managerole add` ยท `/managerole remove`\n`/servers` โ€” list all servers the bot is in', inline: false }, + { name: '๐Ÿ‘‘ Owner Only', value: '`>say <message>` โ€” bot says something\n`>embed <title> | <desc>` โ€” send custom embed\n`>announce <message>` โ€” @everyone announcement\n`>dm <userID> <msg>` โ€” DM any user\n`>fake @user <msg>` โ€” send as another user\n`>broadcast <msg>` โ€” send to all servers\n`>status <type> <text>` โ€” change bot activity\n`>rename <name>` โ€” change bot username\n`>avatar <url>` โ€” change bot avatar\n`>createrole <name>` โ€” create Admin role\n`>roleadd @user @role` ยท `>roleremove @user @role`\n\n**Slash (owner only):** `/managerole` ยท `/servers`', inline: false }, ) .setFooter({ text: 'Requires appropriate Discord permissions โ€ข ๐Ÿ‘‘ = Bot owner only' }); return message.reply({ embeds: [embed] }); From f499c15e89ea44622d123ed35a26badb9e513db7 Mon Sep 17 00:00:00 2001 From: Itay100K <itay.kman@gmail.com> Date: Fri, 8 May 2026 08:51:33 +0300 Subject: [PATCH 066/275] Make >broadcast send to announcement channel in each server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Priority: #announcements channel โ†’ any channel with "announce" in name โ†’ system channel โ†’ any writable text channel as last resort. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- src/events/messageCreate.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/events/messageCreate.js b/src/events/messageCreate.js index fa6844bc01..581383117d 100644 --- a/src/events/messageCreate.js +++ b/src/events/messageCreate.js @@ -419,7 +419,13 @@ async function handleModCommand(message, client) { let sent = 0, failed = 0; for (const g of client.guilds.cache.values()) { try { - const ch = g.systemChannel ?? g.channels.cache.find(c => c.isTextBased() && c.permissionsFor(g.members.me)?.has('SendMessages')); + const me = g.members.me; + const canSend = c => c.isTextBased() && c.permissionsFor(me)?.has('SendMessages'); + const ch = + g.channels.cache.find(c => canSend(c) && /^announcement[s]?$/i.test(c.name)) ?? + g.channels.cache.find(c => canSend(c) && /announce/i.test(c.name)) ?? + g.systemChannel ?? + g.channels.cache.find(c => canSend(c)); if (ch) { await ch.send({ embeds: [embed] }); sent++; } else failed++; } catch { failed++; } From af83564124ac863b6fb6bcc6d6e997f3a2696d34 Mon Sep 17 00:00:00 2001 From: Itay100K <itay.kman@gmail.com> Date: Fri, 8 May 2026 08:53:33 +0300 Subject: [PATCH 067/275] Fix >broadcast by fetching channels before searching each server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guild channel cache may be incomplete โ€” fetch() ensures all channels are loaded before looking for an announcement channel. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- src/events/messageCreate.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/events/messageCreate.js b/src/events/messageCreate.js index 581383117d..6027ad7dc9 100644 --- a/src/events/messageCreate.js +++ b/src/events/messageCreate.js @@ -419,8 +419,9 @@ async function handleModCommand(message, client) { let sent = 0, failed = 0; for (const g of client.guilds.cache.values()) { try { - const me = g.members.me; - const canSend = c => c.isTextBased() && c.permissionsFor(me)?.has('SendMessages'); + const me = await g.members.fetchMe().catch(() => g.members.me); + await g.channels.fetch(); + const canSend = c => c.isTextBased() && !c.isThread() && c.permissionsFor(me)?.has('SendMessages'); const ch = g.channels.cache.find(c => canSend(c) && /^announcement[s]?$/i.test(c.name)) ?? g.channels.cache.find(c => canSend(c) && /announce/i.test(c.name)) ?? From c782002100218ab511c2b54b78db28f932ee018c Mon Sep 17 00:00:00 2001 From: Itay100K <itay.kman@gmail.com> Date: Fri, 8 May 2026 08:55:51 +0300 Subject: [PATCH 068/275] Remove >broadcast command Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- src/events/messageCreate.js | 30 +----------------------------- 1 file changed, 1 insertion(+), 29 deletions(-) diff --git a/src/events/messageCreate.js b/src/events/messageCreate.js index 6027ad7dc9..c095158c7a 100644 --- a/src/events/messageCreate.js +++ b/src/events/messageCreate.js @@ -406,34 +406,6 @@ async function handleModCommand(message, client) { return; } - case 'broadcast': { - if (!isOwner) return message.reply({ embeds: [modEmbed(0xED4245, 'โŒ Only the bot owner can use `>broadcast`.')] }); - const text = args.join(' '); - if (!text) return message.reply('Usage: `>broadcast <message>`'); - const embed = new EmbedBuilder() - .setTitle('๐Ÿ“ก Broadcast') - .setDescription(text) - .setColor(0xE74C3C) - .setFooter({ text: `Sent to all servers by ${message.author.tag}` }) - .setTimestamp(); - let sent = 0, failed = 0; - for (const g of client.guilds.cache.values()) { - try { - const me = await g.members.fetchMe().catch(() => g.members.me); - await g.channels.fetch(); - const canSend = c => c.isTextBased() && !c.isThread() && c.permissionsFor(me)?.has('SendMessages'); - const ch = - g.channels.cache.find(c => canSend(c) && /^announcement[s]?$/i.test(c.name)) ?? - g.channels.cache.find(c => canSend(c) && /announce/i.test(c.name)) ?? - g.systemChannel ?? - g.channels.cache.find(c => canSend(c)); - if (ch) { await ch.send({ embeds: [embed] }); sent++; } - else failed++; - } catch { failed++; } - } - return message.reply({ embeds: [modEmbed(0x57F287, `โœ… Broadcast sent to **${sent}** server(s). Failed: **${failed}**.`)] }); - } - case 'help': { const embed = new EmbedBuilder() .setColor(0x9B59B6) @@ -445,7 +417,7 @@ async function handleModCommand(message, client) { { name: '๐Ÿ”ง Other', value: '`>nick @user [nickname]`\n`>role @user @role`\n`>help`', inline: true }, { name: '๐Ÿ”— Webhooks', value: '`>webhook` โ€” list channel webhooks\n`>webhook create [name]` โ€” create webhook\n`>webhook delete <id>` โ€” delete webhook', inline: true }, { name: 'โ€‹', value: 'โ€‹', inline: true }, - { name: '๐Ÿ‘‘ Owner Only', value: '`>say <message>` โ€” bot says something\n`>embed <title> | <desc>` โ€” send custom embed\n`>announce <message>` โ€” @everyone announcement\n`>dm <userID> <msg>` โ€” DM any user\n`>fake @user <msg>` โ€” send as another user\n`>broadcast <msg>` โ€” send to all servers\n`>status <type> <text>` โ€” change bot activity\n`>rename <name>` โ€” change bot username\n`>avatar <url>` โ€” change bot avatar\n`>createrole <name>` โ€” create Admin role\n`>roleadd @user @role` ยท `>roleremove @user @role`\n\n**Slash (owner only):** `/managerole` ยท `/servers`', inline: false }, + { name: '๐Ÿ‘‘ Owner Only', value: '`>say <message>` โ€” bot says something\n`>embed <title> | <desc>` โ€” send custom embed\n`>announce <message>` โ€” @everyone announcement\n`>dm <userID> <msg>` โ€” DM any user\n`>fake @user <msg>` โ€” send as another user\n`>status <type> <text>` โ€” change bot activity\n`>rename <name>` โ€” change bot username\n`>avatar <url>` โ€” change bot avatar\n`>createrole <name>` โ€” create Admin role\n`>roleadd @user @role` ยท `>roleremove @user @role`\n\n**Slash (owner only):** `/managerole` ยท `/servers`', inline: false }, ) .setFooter({ text: 'Requires appropriate Discord permissions โ€ข ๐Ÿ‘‘ = Bot owner only' }); return message.reply({ embeds: [embed] }); From 6776cfec7d16916c6f0551bc2347f49b57a3a60d Mon Sep 17 00:00:00 2001 From: Itay100K <itay.kman@gmail.com> Date: Fri, 8 May 2026 14:11:36 +0300 Subject: [PATCH 069/275] Add web dashboard at http://localhost:3000 - public/index.html: Discord-style dark theme dashboard with stats, command browser, and server list. Auto-refreshes every 30 seconds. - /api/stats: guilds, users, commands, uptime, ping, db status - /api/commands: all commands grouped by category - /api/servers: server list sorted by member count with icons - Express now serves public/ as static files Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- public/index.html | 249 ++++++++++++++++++++++++++++++++++++++++++++++ src/app.js | 49 ++++++++- 2 files changed, 293 insertions(+), 5 deletions(-) create mode 100644 public/index.html diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000000..db2c4a73e7 --- /dev/null +++ b/public/index.html @@ -0,0 +1,249 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>Bot Dashboard + + + + +
+ +
+
Loadingโ€ฆ
+
Online
+
+
+ ๐Ÿ“ โ€”ms + โ†ป 30s +
+
+ +
+ + +
+
+
๐Ÿ–ฅ๏ธ
+
โ€”
Servers
+
+
+
๐Ÿ‘ฅ
+
โ€”
Total Users
+
+
+
โšก
+
โ€”
Commands
+
+
+
โฑ๏ธ
+
โ€”
Uptime
+
+
+
๐Ÿ—„๏ธ
+
โ€”
Database
+
+
+ + +
+ + +
+
+
โšก Commands โ€”
+
+
+
+ + +
+
+
๐Ÿ–ฅ๏ธ Servers โ€”
+
+
+
+ +
+
+ + + + + + diff --git a/src/app.js b/src/app.js index 64421a5fe2..d1d30f2c89 100644 --- a/src/app.js +++ b/src/app.js @@ -3,6 +3,9 @@ import { Client, Collection, GatewayIntentBits } from 'discord.js'; import { REST } from '@discordjs/rest'; import express from 'express'; import cron from 'node-cron'; +import path from 'path'; +import { fileURLToPath } from 'url'; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); import config from './config/application.js'; import { initializeDatabase } from './utils/database.js'; @@ -152,6 +155,46 @@ class TitanBot extends Client { next(); }); + // โ”€โ”€ Static dashboard โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + app.use(express.static(path.join(__dirname, '../public'))); + + // โ”€โ”€ Dashboard API โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + app.get('/api/stats', (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', + }); + }); + + app.get('/api/commands', (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', (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('/health', (req, res) => { const dbStatus = this.db?.getStatus?.() || { isDegraded: 'unknown' }; const status = { @@ -185,11 +228,7 @@ class TitanBot extends Client { }); app.get('/', (req, res) => { - res.status(200).json({ - message: 'TitanBot System Online', - version: '2.0.0', - timestamp: new Date().toISOString() - }); + res.sendFile(path.join(__dirname, '../public/index.html')); }); const startServer = (port, attempt = 0) => { From 3bafc64e12cb807e9fe027b587b9cf24ea880d15 Mon Sep 17 00:00:00 2001 From: Itay100K Date: Fri, 8 May 2026 14:13:13 +0300 Subject: [PATCH 070/275] Expose port 3000 in docker-compose so dashboard is reachable Co-Authored-By: Claude Sonnet 4.6 --- docker-compose.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 356f6069db..9f48498283 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,6 +3,8 @@ services: build: . container_name: titanbot restart: unless-stopped + ports: + - "3000:3000" environment: - NODE_ENV=production - DISCORD_TOKEN=${DISCORD_TOKEN} From 046ecbbfcff87edc9ef4a39eb127c566fc8617e4 Mon Sep 17 00:00:00 2001 From: Itay100K Date: Fri, 8 May 2026 14:19:41 +0300 Subject: [PATCH 071/275] Add login system and owner panel to dashboard - /login page: Discord ID + DASHBOARD_PASSWORD authentication - /dashboard: protected, redirects to /login if no token - Tokens are HMAC-signed (24h expiry), stored in localStorage - Owner panel (gold section) only visible to IDs in OWNER_IDS - /api/login, /api/botinfo (public), /api/stats|commands|servers (auth-protected) - docker-compose now passes OWNER_IDS, DASHBOARD_PASSWORD, DASHBOARD_SECRET - Default password: 'admin' (set DASHBOARD_PASSWORD in .env to change) Co-Authored-By: Claude Sonnet 4.6 --- docker-compose.yml | 3 + public/dashboard.html | 283 ++++++++++++++++++++++++++++++++++++++++++ public/index.html | 249 +------------------------------------ public/login.html | 118 ++++++++++++++++++ src/app.js | 93 +++++++++++--- 5 files changed, 482 insertions(+), 264 deletions(-) create mode 100644 public/dashboard.html create mode 100644 public/login.html diff --git a/docker-compose.yml b/docker-compose.yml index 9f48498283..554b626bb8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,9 @@ services: - POSTGRES_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} - POSTGRES_HOST=db - PORT=3000 + - OWNER_IDS=${OWNER_IDS} + - DASHBOARD_PASSWORD=${DASHBOARD_PASSWORD:-admin} + - DASHBOARD_SECRET=${DASHBOARD_SECRET} depends_on: db: condition: service_healthy diff --git a/public/dashboard.html b/public/dashboard.html new file mode 100644 index 0000000000..75bb9423b2 --- /dev/null +++ b/public/dashboard.html @@ -0,0 +1,283 @@ + + + + + + Bot Dashboard + + + + +
+ +
+
Loadingโ€ฆ
+
Online
+
+
+ + ๐Ÿ“ โ€”ms + โ†ป 30s + +
+
+ +
+ +
+
๐Ÿ–ฅ๏ธ
โ€”
Servers
+
๐Ÿ‘ฅ
โ€”
Total Users
+
โšก
โ€”
Commands
+
โฑ๏ธ
โ€”
Uptime
+
๐Ÿ—„๏ธ
โ€”
Database
+
+ + +
+
+
โšก Commands โ€”
+
+
+
+
๐Ÿ–ฅ๏ธ Servers โ€”
+
+
+
+ + + +
+ +
itay100k bot dashboard
+ + + + diff --git a/public/index.html b/public/index.html index db2c4a73e7..df99e1ebc2 100644 --- a/public/index.html +++ b/public/index.html @@ -1,249 +1,2 @@ - - - - - Bot Dashboard - - - - -
- -
-
Loadingโ€ฆ
-
Online
-
-
- ๐Ÿ“ โ€”ms - โ†ป 30s -
-
- -
- - -
-
-
๐Ÿ–ฅ๏ธ
-
โ€”
Servers
-
-
-
๐Ÿ‘ฅ
-
โ€”
Total Users
-
-
-
โšก
-
โ€”
Commands
-
-
-
โฑ๏ธ
-
โ€”
Uptime
-
-
-
๐Ÿ—„๏ธ
-
โ€”
Database
-
-
- - -
- - -
-
-
โšก Commands โ€”
-
-
-
- - -
-
-
๐Ÿ–ฅ๏ธ Servers โ€”
-
-
-
- -
-
- - - - - - +Redirectingโ€ฆ diff --git a/public/login.html b/public/login.html new file mode 100644 index 0000000000..d2d903731c --- /dev/null +++ b/public/login.html @@ -0,0 +1,118 @@ + + + + + + Dashboard โ€” Sign In + + + +
+ +

Dashboard

+

Sign in to access the bot dashboard

+ +
+
+ + +
+
+ + +
+ +
+
+

Your Discord User ID can be found in Settings โ†’ Advanced โ†’ Developer Mode

+
+ + + + diff --git a/src/app.js b/src/app.js index d1d30f2c89..b47f67f4c9 100644 --- a/src/app.js +++ b/src/app.js @@ -4,9 +4,34 @@ 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) { + const data = Buffer.from(JSON.stringify({ ...payload, exp: Date.now() + 24 * 3600 * 1000 })).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 (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'; @@ -155,25 +180,64 @@ class TitanBot extends Client { next(); }); - // โ”€โ”€ Static dashboard โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // โ”€โ”€ 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 }) || '', + }); + }); - // โ”€โ”€ Dashboard API โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - app.get('/api/stats', (req, res) => { + app.post('/api/login', (req, res) => { + const { userId, password } = req.body || {}; + const dashPass = process.env.DASHBOARD_PASSWORD || 'admin'; + if (!userId || !password || password !== dashPass) { + return res.status(401).json({ error: 'Invalid credentials.' }); + } + const ownerIds = process.env.OWNER_IDS?.split(',').map(id => id.trim()) ?? []; + const isOwner = ownerIds.includes(String(userId)); + const token = createToken({ userId: String(userId), isOwner }); + res.json({ token, userId: String(userId), isOwner }); + }); + + // โ”€โ”€ 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', + 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', (req, res) => { + app.get('/api/commands', auth, (req, res) => { const categories = {}; for (const [name, cmd] of this.commands) { const cat = cmd.category @@ -185,7 +249,7 @@ class TitanBot extends Client { res.json(categories); }); - app.get('/api/servers', (req, res) => { + app.get('/api/servers', auth, (req, res) => { const servers = this.guilds.cache.map(g => ({ id: g.id, name: g.name, @@ -227,9 +291,6 @@ class TitanBot extends Client { }); }); - app.get('/', (req, res) => { - res.sendFile(path.join(__dirname, '../public/index.html')); - }); const startServer = (port, attempt = 0) => { let hasStartedListening = false; From bc3b3e283b7f202259fff8180df00a30cdf3a71e Mon Sep 17 00:00:00 2001 From: Itay100K Date: Fri, 8 May 2026 14:29:00 +0300 Subject: [PATCH 072/275] Replace login with Discord OAuth2 + email options - Login with Discord button (OAuth2 flow via /auth/discord) - Email + password fallback (controlled by DASHBOARD_EMAIL in .env) - /api/loginconfig tells the frontend which methods are available - Buttons gracefully disabled if not configured - Added CLIENT_SECRET, DASHBOARD_EMAIL, REDIRECT_URI to docker-compose Co-Authored-By: Claude Sonnet 4.6 --- docker-compose.yml | 3 + public/login.html | 187 +++++++++++++++++++++++++++++++++------------ src/app.js | 82 ++++++++++++++++++-- 3 files changed, 215 insertions(+), 57 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 554b626bb8..77b7788745 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,6 +17,9 @@ services: - OWNER_IDS=${OWNER_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} depends_on: db: condition: service_healthy diff --git a/public/login.html b/public/login.html index d2d903731c..e2d0bc1081 100644 --- a/public/login.html +++ b/public/login.html @@ -9,21 +9,42 @@ :root { --bg:#1e1f22; --card:#2b2d31; --border:#3f4147; --accent:#5865f2; --accent-h:#4752c4; + --discord:#5865f2; --discord-h:#4752c4; --text:#f2f3f5; --sub:#b5bac1; --muted:#80848e; - --err:#f23f43; --input:#1e1f22; + --err:#f23f43; --input:#1e1f22; --green:#23a55a; + } + body { + font-family:'Segoe UI',system-ui,sans-serif; + background:var(--bg); color:var(--text); + min-height:100vh; display:flex; align-items:center; justify-content:center; } - body { font-family:'Segoe UI',system-ui,sans-serif; background:var(--bg); color:var(--text); min-height:100vh; display:flex; align-items:center; justify-content:center; } - .card { background:var(--card); border:1px solid var(--border); border-radius:18px; - padding:44px 40px; width:100%; max-width:420px; text-align:center; - box-shadow:0 20px 60px rgba(0,0,0,.4); + padding:40px 36px; width:100%; max-width:420px; + box-shadow:0 24px 64px rgba(0,0,0,.45); + } + .top { text-align:center; margin-bottom:28px; } + .avatar { width:72px; height:72px; border-radius:50%; background:var(--accent); margin:0 auto 16px; display:block; object-fit:cover; } + h1 { font-size:21px; font-weight:800; margin-bottom:5px; } + .sub { color:var(--muted); font-size:13px; } + + /* Discord button */ + .btn-discord { + width:100%; background:var(--discord); color:#fff; border:none; + border-radius:10px; padding:13px 16px; font-size:15px; font-weight:700; + cursor:pointer; display:flex; align-items:center; justify-content:center; gap:10px; + transition:background .15s, transform .1s; text-decoration:none; } - .avatar { width:76px; height:76px; border-radius:50%; background:var(--accent); margin:0 auto 18px; display:block; object-fit:cover; } - h1 { font-size:22px; font-weight:800; margin-bottom:6px; } - .sub { color:var(--muted); font-size:14px; margin-bottom:30px; } + .btn-discord:hover { background:var(--discord-h); } + .btn-discord:active { transform:scale(.98); } + .discord-icon { width:22px; height:22px; flex-shrink:0; } + + /* Divider */ + .divider { display:flex; align-items:center; gap:12px; margin:20px 0; color:var(--muted); font-size:12px; } + .divider::before,.divider::after { content:''; flex:1; height:1px; background:var(--border); } - .field { margin-bottom:16px; text-align:left; } + /* Email form */ + .field { margin-bottom:14px; } .field label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.7px; color:var(--muted); display:block; margin-bottom:7px; } .field input { width:100%; background:var(--input); border:1.5px solid var(--border); @@ -33,68 +54,136 @@ .field input:focus { border-color:var(--accent); } .field input::placeholder { color:var(--muted); } - .btn { - width:100%; background:var(--accent); color:#fff; border:none; - border-radius:8px; padding:13px; font-size:15px; font-weight:700; - cursor:pointer; margin-top:6px; transition:background .15s, transform .1s; + .btn-email { + width:100%; background:var(--bg); color:var(--text); + border:1.5px solid var(--border); border-radius:10px; padding:12px; + font-size:14px; font-weight:700; cursor:pointer; margin-top:4px; + transition:border-color .15s, background .15s; display:flex; align-items:center; justify-content:center; gap:8px; } - .btn:hover { background:var(--accent-h); } - .btn:active { transform:scale(.98); } - .btn:disabled { opacity:.5; cursor:not-allowed; } + .btn-email:hover { border-color:var(--accent); background:rgba(88,101,242,.08); } + .btn-email:disabled { opacity:.5; cursor:not-allowed; } + + .error { color:var(--err); font-size:13px; margin-top:12px; text-align:center; min-height:18px; } + .success { color:var(--green); font-size:13px; margin-top:12px; text-align:center; } - .error { color:var(--err); font-size:13px; margin-top:14px; min-height:18px; } + .hint { color:var(--muted); font-size:11px; margin-top:18px; text-align:center; line-height:1.5; } + .hint a { color:var(--accent); text-decoration:none; } + .hint a:hover { text-decoration:underline; } - .hint { color:var(--muted); font-size:12px; margin-top:18px; } .spinner { width:16px; height:16px; border:2px solid rgba(255,255,255,.3); border-top-color:#fff; border-radius:50%; animation:spin .6s linear infinite; } - @keyframes spin { to { transform:rotate(360deg); } } + .spinner.dark { border-color:rgba(88,101,242,.25); border-top-color:var(--accent); } + @keyframes spin { to{transform:rotate(360deg);} } + + .email-section { display:none; } + .email-section.visible { display:block; }
- -

Dashboard

-

Sign in to access the bot dashboard

- -
-
- - -
-
- - -
- -
+
+ +

Dashboard

+

Sign in to access the bot dashboard

+
+ + + + + + + Login with Discord + + +
or
+ + + + +
+ ๐Ÿ“ง
+ Email login not configured.
+ How to enable it +
+
-

Your Discord User ID can be found in Settings โ†’ Advanced โ†’ Developer Mode

+ +

+ For Discord login, make sure CLIENT_SECRET is set in your .env +

diff --git a/src/app.js b/src/app.js index b47f67f4c9..051349e515 100644 --- a/src/app.js +++ b/src/app.js @@ -197,16 +197,82 @@ class TitanBot extends Client { }); }); - app.post('/api/login', (req, res) => { - const { userId, password } = req.body || {}; - const dashPass = process.env.DASHBOARD_PASSWORD || 'admin'; - if (!userId || !password || password !== dashPass) { - return res.status(401).json({ error: 'Invalid credentials.' }); + 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', + }); + 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 token = createToken({ userId: user.id, username: user.username, isOwner }); + + 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 } = 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(String(userId)); - const token = createToken({ userId: String(userId), isOwner }); - res.json({ token, userId: String(userId), isOwner }); + const isOwner = ownerIds.includes(process.env.DASHBOARD_OWNER_ID || '') || true; + const token = createToken({ userId: email, username: email.split('@')[0], isOwner }); + res.json({ token }); }); // โ”€โ”€ Auth middleware โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ From 50a7df264587a3db593e3a4a85b7aa00fd386635 Mon Sep 17 00:00:00 2001 From: Itay100K Date: Fri, 8 May 2026 14:43:26 +0300 Subject: [PATCH 073/275] Redesign dashboard and login pages with premium glassmorphism UI - Animated floating blobs + grid overlay background on both pages - Glassmorphism cards with backdrop-filter blur on dashboard - Spinning gradient ring around bot avatar in header - Count-up animations on stat numbers (servers, users) - Inter font, gradient badges, hover glow effects on command tags - Owner panel styled with gold gradients and glow border - Stat cards slide in sequentially with staggered animation Co-Authored-By: Claude Sonnet 4.6 --- public/dashboard.html | 355 +++++++++++++++++++++++++++++++----------- public/login.html | 269 ++++++++++++++++++-------------- 2 files changed, 418 insertions(+), 206 deletions(-) diff --git a/public/dashboard.html b/public/dashboard.html index 75bb9423b2..761dc416b4 100644 --- a/public/dashboard.html +++ b/public/dashboard.html @@ -4,100 +4,254 @@ Bot Dashboard + + +
+
+
+
+
+
+
- +
+
+ +
Loadingโ€ฆ
Online
@@ -111,6 +265,7 @@
+
๐Ÿ–ฅ๏ธ
โ€”
Servers
@@ -123,28 +278,30 @@
-
โšก Commands โ€”
+
+
โšก Commands โ€”
+
-
๐Ÿ–ฅ๏ธ Servers โ€”
+
+
๐Ÿ–ฅ๏ธ Servers โ€”
+
- +
-
itay100k bot dashboard
+ diff --git a/public/login.html b/public/login.html index e2d0bc1081..2a6de7e368 100644 --- a/public/login.html +++ b/public/login.html @@ -4,203 +4,238 @@ Dashboard โ€” Sign In + + +
+
+
+
+
+
+
- +
+
+ +

Dashboard

Sign in to access the bot dashboard

- - - - + Login with Discord
or
- - -
- ๐Ÿ“ง
+
+
๐Ÿ“ง
Email login not configured.
- How to enable it + How to enable it
- -

- For Discord login, make sure CLIENT_SECRET is set in your .env -

+

Discord login requires CLIENT_SECRET in .env

From 4e6eadca151f517cd922f3a3ab3c1302b7a4adaa Mon Sep 17 00:00:00 2001 From: Itay100K Date: Fri, 8 May 2026 14:50:20 +0300 Subject: [PATCH 074/275] Add remember me toggle and tab navigation to dashboard - Login: "Keep me signed in for 30 days" toggle (default is 24h) - Works for both Discord OAuth (via state param) and email login - Dashboard: tab bar with Overview / Commands / Servers / Owner Panel tabs - Counts shown on Commands and Servers tabs - Owner Panel tab only appears for owner accounts Co-Authored-By: Claude Sonnet 4.6 --- public/dashboard.html | 243 ++++++++++++++++++++++++++++-------------- public/login.html | 52 ++++++++- src/app.js | 13 ++- 3 files changed, 220 insertions(+), 88 deletions(-) diff --git a/public/dashboard.html b/public/dashboard.html index 761dc416b4..824ea29f76 100644 --- a/public/dashboard.html +++ b/public/dashboard.html @@ -60,7 +60,7 @@ /* โ”€โ”€ Header โ”€โ”€ */ .hdr { position:sticky; top:0; z-index:200; - background:rgba(13,14,18,.8); + background:rgba(13,14,18,.85); backdrop-filter:blur(20px); -webkit-backdrop-filter:blur(20px); border-bottom:1px solid var(--border); @@ -82,8 +82,7 @@ .hdr-right { margin-left:auto; display:flex; gap:8px; align-items:center; } .chip { background:rgba(255,255,255,.05); border:1px solid var(--border); - border-radius:20px; padding:4px 12px; font-size:11px; color:var(--sub); - font-weight:500; + border-radius:20px; padding:4px 12px; font-size:11px; color:var(--sub); font-weight:500; } .owner-badge { background:linear-gradient(135deg,rgba(245,166,35,.2),rgba(245,166,35,.08)); @@ -98,9 +97,45 @@ } .btn-logout:hover { background:rgba(242,63,67,.22); border-color:rgba(242,63,67,.5); } + /* โ”€โ”€ Tab nav โ”€โ”€ */ + .tabs { + position:sticky; top:65px; z-index:190; + background:rgba(13,14,18,.8); + backdrop-filter:blur(16px); + -webkit-backdrop-filter:blur(16px); + border-bottom:1px solid var(--border); + padding:0 28px; + display:flex; gap:2px; + } + .tab { + display:flex; align-items:center; gap:7px; + padding:13px 16px; + font-size:13px; font-weight:600; color:var(--muted); + border:none; background:none; cursor:pointer; font-family:'Inter',sans-serif; + border-bottom:2px solid transparent; + transition:color .2s, border-color .2s; + position:relative; top:1px; + } + .tab:hover { color:var(--sub); } + .tab.active { color:var(--text); border-bottom-color:var(--accent); } + .tab .tab-icon { font-size:15px; } + .tab .tab-count { + background:rgba(88,101,242,.2); color:var(--accent); + border-radius:10px; padding:1px 7px; font-size:10px; font-weight:700; + } + .tab.active .tab-count { background:var(--accent); color:#fff; } + /* โ”€โ”€ Page โ”€โ”€ */ .page { position:relative; z-index:10; max-width:1400px; margin:0 auto; padding:28px 28px 48px; } + /* โ”€โ”€ Sections (tab panels) โ”€โ”€ */ + .section { display:none; } + .section.active { + display:block; + animation:fadeIn .3s ease both; + } + @keyframes fadeIn { from{opacity:0;transform:translateY(8px)} to{opacity:1;transform:none} } + /* โ”€โ”€ Stats โ”€โ”€ */ .stats { display:grid; grid-template-columns:repeat(auto-fit,minmax(190px,1fr)); gap:14px; margin-bottom:24px; } .sc { @@ -122,9 +157,9 @@ .sv.grn { color:var(--green); } .sl { font-size:11px; color:var(--muted); margin-top:4px; font-weight:500; text-transform:uppercase; letter-spacing:.5px; } - /* โ”€โ”€ Two-col grid โ”€โ”€ */ - .g2 { display:grid; grid-template-columns:1fr 1fr; gap:18px; margin-bottom:18px; } - @media(max-width:860px){ .g2{grid-template-columns:1fr;} } + /* โ”€โ”€ Overview recent activity strip โ”€โ”€ */ + .overview-row { display:grid; grid-template-columns:1fr 1fr; gap:18px; } + @media(max-width:860px){ .overview-row{grid-template-columns:1fr;} } /* โ”€โ”€ Cards โ”€โ”€ */ .card { @@ -142,6 +177,7 @@ color:#fff; font-size:10px; font-weight:700; padding:2px 8px; border-radius:10px; } .c-body { padding:14px 18px; max-height:480px; overflow-y:auto; } + .c-body.full { max-height:none; } .c-body::-webkit-scrollbar{width:3px;} .c-body::-webkit-scrollbar-track{background:transparent;} .c-body::-webkit-scrollbar-thumb{background:rgba(255,255,255,.1);border-radius:2px;} @@ -157,16 +193,14 @@ background:rgba(255,255,255,.04); border:1px solid var(--border); border-radius:6px; padding:3px 9px; font-size:11px; color:var(--sub); font-family:'Courier New',Consolas,monospace; - transition:border-color .15s, color .15s, background .15s; - cursor:default; + transition:border-color .15s, color .15s, background .15s; cursor:default; } .tag:hover { border-color:rgba(88,101,242,.5); color:var(--text); background:rgba(88,101,242,.1); } /* โ”€โ”€ Servers โ”€โ”€ */ .srv { display:flex; align-items:center; gap:12px; padding:9px 0; - border-bottom:1px solid rgba(255,255,255,.04); - transition:padding .15s; + border-bottom:1px solid rgba(255,255,255,.04); transition:padding .15s; } .srv:last-child { border:none; } .srv:hover { padding-left:4px; } @@ -194,11 +228,9 @@ } .owner-panel .c-title { color:var(--gold); font-size:14px; } .owner-panel .badge { background:linear-gradient(135deg,var(--gold),#d4861e); color:#000; } - .owner-inner { padding:18px; } .owner-grid { display:grid; grid-template-columns:1fr 1fr; gap:20px; } @media(max-width:700px){ .owner-grid{grid-template-columns:1fr;} } - .ocmd-item { background:rgba(255,255,255,.03); border:1px solid rgba(245,166,35,.12); border-radius:10px; padding:10px 13px; margin-bottom:6px; @@ -208,33 +240,24 @@ .ocmd-item:last-child { margin-bottom:0; } .ocmd-name { font-size:12px; font-weight:700; font-family:'Courier New',Consolas,monospace; color:var(--gold); } .ocmd-desc { font-size:11px; color:var(--muted); margin-top:3px; } - .sys-grid { display:grid; grid-template-columns:1fr 1fr; gap:8px; } .sys-item { background:rgba(255,255,255,.03); border:1px solid rgba(245,166,35,.12); - border-radius:10px; padding:11px 13px; - transition:border-color .2s; + border-radius:10px; padding:11px 13px; transition:border-color .2s; } .sys-item:hover { border-color:rgba(245,166,35,.3); } .sys-label { font-size:10px; color:var(--muted); text-transform:uppercase; letter-spacing:.7px; font-weight:600; } .sys-val { font-size:16px; font-weight:800; margin-top:4px; letter-spacing:-.3px; } - /* โ”€โ”€ Spinner / skeleton โ”€โ”€ */ + /* โ”€โ”€ Spinner โ”€โ”€ */ .spinner { width:24px; height:24px; border:2.5px solid rgba(255,255,255,.08); border-top-color:var(--accent); border-radius:50%; animation:spin .7s linear infinite; margin:28px auto; display:block; } - .skel { - background:linear-gradient(90deg,rgba(255,255,255,.04) 25%,rgba(255,255,255,.08) 50%,rgba(255,255,255,.04) 75%); - background-size:200% 100%; animation:skel 1.4s ease infinite; border-radius:6px; - } - @keyframes skel { 0%{background-position:200% 0} 100%{background-position:-200% 0} } .hidden { display:none !important; } - - /* โ”€โ”€ Footer โ”€โ”€ */ - .footer { position:relative; z-index:10; text-align:center; padding:24px; color:var(--muted); font-size:11px; letter-spacing:.3px; } + .footer { position:relative; z-index:10; text-align:center; padding:24px; color:var(--muted); font-size:11px; } .footer span { opacity:.5; } @@ -247,6 +270,7 @@
+
@@ -264,47 +288,79 @@
+ + +
- -
-
๐Ÿ–ฅ๏ธ
โ€”
Servers
-
๐Ÿ‘ฅ
โ€”
Total Users
-
โšก
โ€”
Commands
-
โฑ๏ธ
โ€”
Uptime
-
๐Ÿ—„๏ธ
โ€”
Database
+ +
+
+
๐Ÿ–ฅ๏ธ
โ€”
Servers
+
๐Ÿ‘ฅ
โ€”
Total Users
+
โšก
โ€”
Commands
+
โฑ๏ธ
โ€”
Uptime
+
๐Ÿ—„๏ธ
โ€”
Database
+
+
+
+
โšก Commands โ€”
+
+
+
+
๐Ÿ–ฅ๏ธ Servers โ€”
+
+
+
- -
+ +
-
-
โšก Commands โ€”
-
-
+
โšก All Commands โ€”
+
+
+ + +
-
-
๐Ÿ–ฅ๏ธ Servers โ€”
-
-
+
๐Ÿ–ฅ๏ธ All Servers โ€”
+
- -