diff --git a/.env.example b/.env.example index c2eae4ad28..d7ecd0a16f 100644 --- a/.env.example +++ b/.env.example @@ -19,10 +19,13 @@ WEB_HOST=0.0.0.0 PORT_RETRY_ATTEMPTS=5 CORS_ORIGIN=* +# Security Dashboard +# Generate a long random value and set it in Railway/environment variables. +SECURITY_DASHBOARD_TOKEN=replace_with_a_long_random_secret +# Optional: restrict dashboard access to one guild. Defaults to GUILD_ID when present. +SECURITY_DASHBOARD_GUILD_ID= + # PostgreSQL Configuration (Primary Database) -# Railway: use the private POSTGRES_URL / DATABASE_URL variable (includes SSL). -# Public proxy logs showing "invalid length of startup packet" or "SSL without ALPN" -# are usually internet scanners — not your bot. POSTGRES_URL=postgresql://postgres:yourpassword@localhost:5432/titanbot POSTGRES_SSL= POSTGRES_HOST=localhost @@ -52,12 +55,8 @@ BACKUP_RETENTION_DAYS=14 POSTGRES_RESTORE_URL= # Music (Lavalink + Riffy) — requires Lavalink v4 nodes -# Default: loads public v4 SSL nodes from lavalink/nodes.json -# Source: https://lavalink.darrennathanael.com/SSL/Lavalink-SSL/ # LAVALINK_NODES_FILE=lavalink/nodes.json -# Optional: override nodes as JSON array (takes priority over nodes file) # LAVALINK_NODES=[{"host":"lavalink","port":2333,"password":"youshallnotpass","secure":false,"name":"Main"}] -# Self-hosted single node fallback (used only if nodes file/env array are absent): # LAVALINK_HOST=localhost # LAVALINK_PORT=2333 # LAVALINK_PASSWORD=youshallnotpass diff --git a/.github/workflows/javascript-syntax.yml b/.github/workflows/javascript-syntax.yml new file mode 100644 index 0000000000..90f189c45b --- /dev/null +++ b/.github/workflows/javascript-syntax.yml @@ -0,0 +1,23 @@ +name: JavaScript Syntax Check + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + syntax: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - name: Check JavaScript syntax + shell: bash + run: | + set -euo pipefail + find src scripts -type f -name '*.js' -print0 | while IFS= read -r -d '' file; do + node --check "$file" + done diff --git a/lavalink/nodes.json b/lavalink/nodes.json index 78e107cec4..9df29b5206 100644 --- a/lavalink/nodes.json +++ b/lavalink/nodes.json @@ -1,30 +1,9 @@ [ { - "host": "lavalinkv4.serenetia.com", + "host": "lavalink.lavalink.is", "port": 443, - "password": "https://seretia.link/discord", + "password": "password", "secure": true, - "name": "Serenetia" - }, - { - "host": "lavalink.jirayu.net", - "port": 443, - "password": "youshallnotpass", - "secure": true, - "name": "Jirayu" - }, - { - "host": "lava-v4.millohost.my.id", - "port": 443, - "password": "https://discord.gg/mjS5J2K3ep", - "secure": true, - "name": "MilloHost" - }, - { - "host": "lavalink-v4.triniumhost.com", - "port": 443, - "password": "free", - "secure": true, - "name": "TriniumHost" + "name": "Public-Node" } ] diff --git a/src/app.js b/src/app.js index 693cd228ea..227a997a54 100644 --- a/src/app.js +++ b/src/app.js @@ -9,7 +9,6 @@ import { initializeDatabase } from './utils/database.js'; import { getGuildConfig } from './services/config/guildConfig.js'; import { getServerCounters, saveServerCounters, updateCounter } from './services/serverstatsService.js'; import { logger, startupLog, shutdownLog } from './utils/logger.js'; -import { checkBirthdays } from './services/birthdayService.js'; import { checkGiveaways } from './services/giveawayService.js'; import { loadCommands, registerCommands as registerSlashCommands } from './handlers/loaders/commandLoader.js'; import { runSafeTask, handleTaskError, ErrorCodes } from './utils/errorHandler.js'; @@ -248,7 +247,6 @@ class TitanBot extends Client { } setupCronJobs() { - cron.schedule('0 6 * * *', runSafeTask('birthday_check', () => checkBirthdays(this))); cron.schedule('* * * * *', runSafeTask('giveaway_check', () => checkGiveaways(this))); cron.schedule('*/15 * * * *', runSafeTask('counter_update', () => this.updateAllCounters())); } @@ -428,4 +426,4 @@ try { process.exit(1); } -export default TitanBot; \ No newline at end of file +export default TitanBot; diff --git a/src/commands/Birthday/birthday.js b/src/commands/Birthday/birthday.js deleted file mode 100644 index c1f8672d00..0000000000 --- a/src/commands/Birthday/birthday.js +++ /dev/null @@ -1,97 +0,0 @@ -import { SlashCommandBuilder, MessageFlags, ChannelType } from 'discord.js'; -import { createEmbed, successEmbed } from '../../utils/embeds.js'; -import { replyUserError, ErrorTypes } 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) { - const subcommand = interaction.options.getSubcommand(); - - switch (subcommand) { - case 'set': - return await birthdaySet.execute(interaction, config, client); - case 'info': - return await birthdayInfo.execute(interaction, config, client); - case 'list': - return await birthdayList.execute(interaction, config, client); - case 'remove': - return await birthdayRemove.execute(interaction, config, client); - case 'next': - return await nextBirthdays.execute(interaction, config, client); - case 'setchannel': - return await birthdaySetchannel.execute(interaction, config, client); - default: - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Unknown subcommand' }); - } - } -}; \ No newline at end of file diff --git a/src/commands/Birthday/modules/birthday_info.js b/src/commands/Birthday/modules/birthday_info.js deleted file mode 100644 index 07b8c20020..0000000000 --- a/src/commands/Birthday/modules/birthday_info.js +++ /dev/null @@ -1,44 +0,0 @@ -import { EmbedBuilder } from 'discord.js'; -import { getUserBirthday } from '../../../services/birthdayService.js'; -import { logger } from '../../../utils/logger.js'; - -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -export default { - async execute(interaction, config, client) { - await InteractionHelper.safeDefer(interaction); - - const targetUser = interaction.options.getUser("user") || interaction.user; - const userId = targetUser.id; - const guildId = interaction.guildId; - - const birthdayData = await getUserBirthday(client, guildId, userId); - - if (!birthdayData) { - const embed = new EmbedBuilder() - .setColor(0xFF0000) - .setTitle('No Birthday Found') - .setDescription(targetUser.id === interaction.user.id - ? "You haven't set your birthday yet. Use `/birthday set` to add it!" - : `${targetUser.username} hasn't set their birthday yet.`); - return await InteractionHelper.safeEditReply(interaction, { - embeds: [embed] - }); - } - - const embed = new EmbedBuilder() - .setColor(0x00FF00) - .setTitle('Birthday Information') - .setDescription(`**Date:** ${birthdayData.monthName} ${birthdayData.day}\n**User:** ${targetUser.toString()}`); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [embed] - }); - - logger.info('Birthday info retrieved successfully', { - userId: interaction.user.id, - targetUserId: targetUser.id, - guildId, - commandName: 'birthday_info' - }); - } -}; \ No newline at end of file diff --git a/src/commands/Birthday/modules/birthday_list.js b/src/commands/Birthday/modules/birthday_list.js deleted file mode 100644 index ce7ee064da..0000000000 --- a/src/commands/Birthday/modules/birthday_list.js +++ /dev/null @@ -1,76 +0,0 @@ -import { EmbedBuilder } from 'discord.js'; -import { getAllBirthdays } from '../../../services/birthdayService.js'; -import { deleteBirthday } from '../../../utils/database.js'; -import { logger } from '../../../utils/logger.js'; - -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -export default { - async execute(interaction, config, client) { - await InteractionHelper.safeDefer(interaction); - - const guildId = interaction.guildId; - - const sortedBirthdays = await getAllBirthdays(client, guildId); - - if (sortedBirthdays.length === 0) { - const embed = new EmbedBuilder() - .setColor(0xFF0000) - .setTitle('No Birthdays') - .setDescription('No birthdays have been set in this server yet.'); - return await InteractionHelper.safeEditReply(interaction, { - embeds: [embed] - }); - } - - const userIds = sortedBirthdays.map(b => b.userId); - const fetchedMembers = await interaction.guild.members.fetch({ user: userIds }).catch(() => null); - - 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`; - } - - if (fetchedMembers && staleUserIds.length > 0) { - for (const userId of staleUserIds) { - deleteBirthday(client, guildId, userId).catch(() => null); - } - } - - if (displayIndex === 0) { - const embed = new EmbedBuilder() - .setColor(0xFF0000) - .setTitle('No Birthdays') - .setDescription('No birthdays have been set by current server members.'); - return await InteractionHelper.safeEditReply(interaction, { - embeds: [embed] - }); - } - - birthdayList = `**${displayIndex} birthday${displayIndex !== 1 ? 's' : ''} in ${interaction.guild.name}**\n\n` + birthdayList; - - const embed = new EmbedBuilder() - .setColor(0x00FF00) - .setTitle('Server Birthdays') - .setDescription(`${birthdayList}\n\nTotal: ${displayIndex} birthday${displayIndex !== 1 ? 's' : ''}`); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [embed] - }); - - logger.info('Birthday list retrieved successfully', { - userId: interaction.user.id, - guildId, - birthdayCount: displayIndex, - staleRemoved: staleUserIds.length, - commandName: 'birthday_list' - }); - } -}; \ No newline at end of file diff --git a/src/commands/Birthday/modules/birthday_remove.js b/src/commands/Birthday/modules/birthday_remove.js deleted file mode 100644 index 2494c42768..0000000000 --- a/src/commands/Birthday/modules/birthday_remove.js +++ /dev/null @@ -1,33 +0,0 @@ -import { EmbedBuilder } from 'discord.js'; -import { deleteBirthday } from '../../../services/birthdayService.js'; - -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -export default { - async execute(interaction, config, client) { - await InteractionHelper.safeDefer(interaction); - - const userId = interaction.user.id; - const guildId = interaction.guildId; - - const result = await deleteBirthday(client, guildId, userId); - - if (result.status === 'not_found') { - const embed = new EmbedBuilder() - .setColor(0xFF0000) - .setTitle('No Birthday Found') - .setDescription('You don\'t have a birthday set to remove.'); - await InteractionHelper.safeEditReply(interaction, { - embeds: [embed] - }); - return; - } - - const embed = new EmbedBuilder() - .setColor(0x00FF00) - .setTitle('Birthday Removed') - .setDescription('Your birthday has been successfully removed from the server.'); - await InteractionHelper.safeEditReply(interaction, { - embeds: [embed] - }); - } -}; \ No newline at end of file diff --git a/src/commands/Birthday/modules/birthday_set.js b/src/commands/Birthday/modules/birthday_set.js deleted file mode 100644 index bf968b065d..0000000000 --- a/src/commands/Birthday/modules/birthday_set.js +++ /dev/null @@ -1,25 +0,0 @@ -import { EmbedBuilder } from 'discord.js'; -import { setBirthday } from '../../../services/birthdayService.js'; - -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -export default { - async execute(interaction, config, client) { - await InteractionHelper.safeDefer(interaction); - - const month = interaction.options.getInteger("month"); - const day = interaction.options.getInteger("day"); - const userId = interaction.user.id; - const guildId = interaction.guildId; - - const result = await setBirthday(client, guildId, userId, month, day); - - const embed = new EmbedBuilder() - .setColor(0x00FF00) - .setTitle('Birthday Set!') - .setDescription(`Your birthday has been set to **${result.data.monthName} ${result.data.day}**!`); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [embed] - }); - } -}; \ No newline at end of file diff --git a/src/commands/Birthday/modules/birthday_setchannel.js b/src/commands/Birthday/modules/birthday_setchannel.js deleted file mode 100644 index 09083bfa9f..0000000000 --- a/src/commands/Birthday/modules/birthday_setchannel.js +++ /dev/null @@ -1,59 +0,0 @@ -import { PermissionsBitField, EmbedBuilder, MessageFlags } from 'discord.js'; -import { getGuildConfig, setGuildConfig } from '../../../services/config/guildConfig.js'; -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -import { logger } from '../../../utils/logger.js'; - -export default { - async execute(interaction, config, client) { - if (!interaction.member.permissions.has(PermissionsBitField.Flags.ManageGuild)) { - const embed = new EmbedBuilder() - .setColor(0xFF0000) - .setTitle('Permission Denied') - .setDescription('You need **Manage Server** permissions to configure the birthday channel.'); - return InteractionHelper.safeReply(interaction, { - embeds: [embed], - 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); - const embed = new EmbedBuilder() - .setColor(0x00FF00) - .setTitle('Birthday Announcements Enabled') - .setDescription(`Birthday announcements will now be posted in ${channel}.`); - return InteractionHelper.safeReply(interaction, { - embeds: [embed], - flags: MessageFlags.Ephemeral, - }); - } else { - guildConfig.birthdayChannelId = null; - await setGuildConfig(client, guildId, guildConfig); - const embed = new EmbedBuilder() - .setColor(0xFFFF00) - .setTitle('Birthday Announcements Disabled') - .setDescription('No channel provided — birthday announcements have been disabled.'); - return InteractionHelper.safeReply(interaction, { - embeds: [embed], - flags: MessageFlags.Ephemeral, - }); - } - } catch (error) { - logger.error('birthday_setchannel error:', error); - const embed = new EmbedBuilder() - .setColor(0xFF0000) - .setTitle('⚠️ Configuration Error') - .setDescription('Could not save the birthday channel configuration.'); - return InteractionHelper.safeReply(interaction, { - embeds: [embed], - flags: MessageFlags.Ephemeral, - }); - } - }, -}; \ No newline at end of file diff --git a/src/commands/Birthday/modules/next_birthdays.js b/src/commands/Birthday/modules/next_birthdays.js deleted file mode 100644 index 0695347c16..0000000000 --- a/src/commands/Birthday/modules/next_birthdays.js +++ /dev/null @@ -1,91 +0,0 @@ -import { EmbedBuilder } from 'discord.js'; -import { getUpcomingBirthdays } from '../../../services/birthdayService.js'; -import { deleteBirthday } from '../../../utils/database.js'; -import { logger } from '../../../utils/logger.js'; - -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -export default { - async execute(interaction, config, client) { - await InteractionHelper.safeDefer(interaction); - - const next5 = await getUpcomingBirthdays(client, interaction.guildId, 5); - - if (next5.length === 0) { - const embed = new EmbedBuilder() - .setColor(0xFF0000) - .setTitle('No Birthdays Found') - .setDescription('No birthdays have been set up in this server yet. Use `/birthday set` to add birthdays!'); - return await InteractionHelper.safeEditReply(interaction, { - embeds: [embed] - }); - } - - let displayIndex = 0; - for (const birthday of next5) { - const member = await interaction.guild.members.fetch(birthday.userId).catch(() => null); - if (!member) { - deleteBirthday(client, interaction.guildId, birthday.userId).catch(() => null); - continue; - } - displayIndex++; - - 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' : ''}`; - } - } - - if (displayIndex === 0) { - const embed = new EmbedBuilder() - .setColor(0xFF0000) - .setTitle('No Upcoming Birthdays') - .setDescription('No upcoming birthdays found for current server members.'); - return await InteractionHelper.safeEditReply(interaction, { - embeds: [embed] - }); - } - - let birthdayList = `🎂 **Next 5 Upcoming Birthdays**\n\nHere are the next 5 birthdays in ${interaction.guild.name}:\n\n`; - displayIndex = 0; - for (const birthday of next5) { - const member = await interaction.guild.members.fetch(birthday.userId).catch(() => null); - if (!member) { - continue; - } - displayIndex++; - - let timeUntil = ''; - if (birthday.daysUntil === 0) { - timeUntil = '🎉 **Today!**'; - } else if (birthday.daysUntil === 1) { - timeUntil = '📅 **Tomorrow!**'; - } else { - timeUntil = `In ${birthday.daysUntil} day${birthday.daysUntil > 1 ? 's' : ''}`; - } - - birthdayList += `${displayIndex}. **${member.displayName}**\n<@${birthday.userId}>\n📅 **Date:** ${birthday.monthName} ${birthday.day}\n⏰ **Time:** ${timeUntil}\n\n`; - } - - birthdayList += `Use /birthday set to add your birthday!`; - - const embed = new EmbedBuilder() - .setColor(0x00FF00) - .setTitle('Next 5 Upcoming Birthdays') - .setDescription(birthdayList); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [embed] - }); - - logger.info('Next birthdays retrieved successfully', { - userId: interaction.user.id, - guildId: interaction.guildId, - upcomingCount: displayIndex, - commandName: 'next_birthdays' - }); - } -}; \ No newline at end of file diff --git a/src/commands/Community/autoreaction.js b/src/commands/Community/autoreaction.js new file mode 100644 index 0000000000..779e3ddece --- /dev/null +++ b/src/commands/Community/autoreaction.js @@ -0,0 +1,82 @@ +import { ChannelType, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; +import { getGuildConfig, updateGuildConfig } from '../../services/config/guildConfig.js'; +import { withErrorHandling } from '../../utils/errorHandler.js'; + +function normalizeEmoji(value) { + return String(value ?? '').trim(); +} + +export default { + data: new SlashCommandBuilder() + .setName('autoreaction') + .setDescription('Manage automatic reactions for a channel') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) + .addSubcommand((sub) => sub + .setName('setup') + .setDescription('Set the channel and reaction') + .addChannelOption((opt) => opt + .setName('channel') + .setDescription('Channel where every new message gets the reaction') + .addChannelTypes(ChannelType.GuildText) + .setRequired(true)) + .addStringOption((opt) => opt + .setName('reaction') + .setDescription('Emoji or custom Discord emoji') + .setRequired(true) + .setMaxLength(100))) + .addSubcommand((sub) => sub + .setName('disable') + .setDescription('Disable automatic reactions')) + .addSubcommand((sub) => sub + .setName('status') + .setDescription('Show the current automatic reaction settings')), + category: 'Community', + execute: withErrorHandling(async (interaction, guildConfig) => { + if (!interaction.inGuild()) { + return interaction.reply({ content: 'This command can only be used in a server.', ephemeral: true }); + } + + const config = guildConfig || await getGuildConfig(interaction.client, interaction.guildId); + const current = config.autoReaction || { enabled: false, channelId: null, reaction: null }; + const sub = interaction.options.getSubcommand(); + + if (sub === 'disable') { + await updateGuildConfig(interaction.client, interaction.guildId, { + autoReaction: { ...current, enabled: false }, + }); + return interaction.reply({ content: 'Automatic reactions have been disabled.', ephemeral: true }); + } + + if (sub === 'status') { + if (!current.enabled || !current.channelId || !current.reaction) { + return interaction.reply({ content: 'Automatic reactions are currently disabled.', ephemeral: true }); + } + return interaction.reply({ + content: `**Auto Reaction**\nChannel: <#${current.channelId}>\nReaction: ${current.reaction}\nStatus: **Enabled**`, + ephemeral: true, + }); + } + + const channel = interaction.options.getChannel('channel', true); + const reaction = normalizeEmoji(interaction.options.getString('reaction', true)); + if (!reaction) return interaction.reply({ content: 'The reaction cannot be empty.', ephemeral: true }); + + const me = interaction.guild.members.me; + if (!me?.permissionsIn(channel).has('AddReactions')) { + return interaction.reply({ content: 'I need the Add Reactions permission in that channel.', ephemeral: true }); + } + + await updateGuildConfig(interaction.client, interaction.guildId, { + autoReaction: { + enabled: true, + channelId: channel.id, + reaction, + }, + }); + + return interaction.reply({ + content: `Auto Reaction enabled.\nChannel: <#${channel.id}>\nReaction: ${reaction}`, + ephemeral: true, + }); + }), +}; diff --git a/src/commands/Community/autoreply.js b/src/commands/Community/autoreply.js new file mode 100644 index 0000000000..d1970dd907 --- /dev/null +++ b/src/commands/Community/autoreply.js @@ -0,0 +1,102 @@ +import { PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; +import { getGuildConfig, updateGuildConfig } from '../../services/config/guildConfig.js'; +import { withErrorHandling } from '../../utils/errorHandler.js'; + +const MAX_RULES = 100; +const MAX_TRIGGER_LENGTH = 500; +const MAX_RESPONSE_LENGTH = 2000; + +function normalizeText(value) { + return String(value ?? '').trim(); +} + +export default { + data: new SlashCommandBuilder() + .setName('autoreply') + .setDescription('Manage exact-match automatic replies') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) + .addSubcommand((sub) => sub + .setName('add') + .setDescription('Add an automatic reply') + .addStringOption((opt) => opt + .setName('message') + .setDescription('The exact message that should trigger the reply') + .setRequired(true) + .setMaxLength(MAX_TRIGGER_LENGTH)) + .addStringOption((opt) => opt + .setName('reply') + .setDescription('What the bot should reply with') + .setRequired(true) + .setMaxLength(MAX_RESPONSE_LENGTH))) + .addSubcommand((sub) => sub + .setName('remove') + .setDescription('Remove an automatic reply') + .addStringOption((opt) => opt + .setName('message') + .setDescription('The exact message to remove') + .setRequired(true) + .setMaxLength(MAX_TRIGGER_LENGTH))) + .addSubcommand((sub) => sub + .setName('list') + .setDescription('List automatic replies')), + category: 'Community', + execute: withErrorHandling(async (interaction, guildConfig) => { + if (!interaction.inGuild()) { + return interaction.reply({ content: 'This command can only be used in a server.', ephemeral: true }); + } + + const config = guildConfig || await getGuildConfig(interaction.client, interaction.guildId); + const rules = Array.isArray(config.autoReplies) ? config.autoReplies : []; + const sub = interaction.options.getSubcommand(); + + if (sub === 'list') { + if (!rules.length) { + return interaction.reply({ content: 'No automatic replies are configured.', ephemeral: true }); + } + + const lines = rules.map((rule, index) => + `**${index + 1}.** \`${rule.trigger.replace(/`/g, '\\`')}\` → ${rule.response}` + ); + return interaction.reply({ content: `**Automatic Replies (${rules.length})**\n${lines.join('\n')}`, ephemeral: true }); + } + + const trigger = normalizeText(interaction.options.getString('message', true)); + if (!trigger) return interaction.reply({ content: 'The trigger message cannot be empty.', ephemeral: true }); + + if (sub === 'remove') { + const index = rules.findIndex((rule) => rule.trigger === trigger); + if (index === -1) { + return interaction.reply({ content: 'No automatic reply was found for that exact message.', ephemeral: true }); + } + + const updatedRules = rules.filter((_, ruleIndex) => ruleIndex !== index); + await updateGuildConfig(interaction.client, interaction.guildId, { autoReplies: updatedRules }); + return interaction.reply({ content: `Removed the automatic reply for: \`${trigger}\``, ephemeral: true }); + } + + const response = normalizeText(interaction.options.getString('reply', true)); + if (!response) return interaction.reply({ content: 'The reply cannot be empty.', ephemeral: true }); + + const existingIndex = rules.findIndex((rule) => rule.trigger === trigger); + const nextRule = { trigger, response }; + let updatedRules; + + if (existingIndex >= 0) { + updatedRules = [...rules]; + updatedRules[existingIndex] = nextRule; + } else { + if (rules.length >= MAX_RULES) { + return interaction.reply({ content: `You can have a maximum of ${MAX_RULES} automatic replies.`, ephemeral: true }); + } + updatedRules = [...rules, nextRule]; + } + + await updateGuildConfig(interaction.client, interaction.guildId, { autoReplies: updatedRules }); + return interaction.reply({ + content: existingIndex >= 0 + ? `Updated the automatic reply for: \`${trigger}\`` + : `Added an automatic reply for: \`${trigger}\``, + ephemeral: true, + }); + }), +}; diff --git a/src/commands/Community/clan.js b/src/commands/Community/clan.js new file mode 100644 index 0000000000..57abb7e5b8 --- /dev/null +++ b/src/commands/Community/clan.js @@ -0,0 +1,178 @@ +import { ChannelType, EmbedBuilder, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; +import { addClanMember, createClan, deleteClan, getClan, getClanForUser, getClans, makeClanId, removeClanMember, sanitizeClanName, updateClan } from '../../services/clanService.js'; + +const clanCommand = new SlashCommandBuilder() + .setName('clan').setDescription('Manage clans and your clan').setDMPermission(false) + .addSubcommandGroup((group) => group.setName('admin').setDescription('Administrator clan controls') + .addSubcommand((sub) => sub.setName('create').setDescription('Create a clan for a user') + .addUserOption((opt) => opt.setName('owner').setDescription('Clan owner').setRequired(true)) + .addStringOption((opt) => opt.setName('name').setDescription('Clan name').setRequired(true).setMaxLength(50))) + .addSubcommand((sub) => sub.setName('delete').setDescription('Delete a clan and its channels') + .addStringOption((opt) => opt.setName('clan_id').setDescription('Clan ID').setRequired(true))) + .addSubcommand((sub) => sub.setName('list').setDescription('List all clans')) + .addSubcommand((sub) => sub.setName('info').setDescription('View a clan') + .addStringOption((opt) => opt.setName('clan_id').setDescription('Clan ID').setRequired(true)))) + .addSubcommand((sub) => sub.setName('info').setDescription('View your clan')) + .addSubcommand((sub) => sub.setName('members').setDescription('View your clan members')) + .addSubcommand((sub) => sub.setName('add').setDescription('Add a member to your clan') + .addUserOption((opt) => opt.setName('user').setDescription('Member to add').setRequired(true))) + .addSubcommand((sub) => sub.setName('remove').setDescription('Remove a member from your clan') + .addUserOption((opt) => opt.setName('user').setDescription('Member to remove').setRequired(true))) + .addSubcommand((sub) => sub.setName('rename').setDescription('Rename your clan') + .addStringOption((opt) => opt.setName('name').setDescription('New clan name').setRequired(true).setMaxLength(50))); + +const adminAllowed = (interaction) => interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild); + +function clanEmbed(clan) { + return new EmbedBuilder().setTitle(`Clan • ${clan.name}`) + .addFields( + { name: 'Clan ID', value: `\`${clan.id}\``, inline: true }, + { name: 'Owner', value: `<@${clan.ownerId}>`, inline: true }, + { name: 'Members', value: `**${clan.memberIds.length + 1}**`, inline: true }, + { name: 'Role', value: `<@&${clan.roleId}>`, inline: true }, + { name: 'Text', value: `<#${clan.textChannelId}>`, inline: true }, + { name: 'Voice', value: `<#${clan.voiceChannelId}>`, inline: true }, + ).setTimestamp(); +} + +async function createClanResources(interaction, owner, name) { + const guild = interaction.guild; + const role = await guild.roles.create({ name: name.slice(0, 100), reason: `Clan created by ${interaction.user.tag}` }); + let category; + let textChannel; + let voiceChannel; + try { + const overwrites = [ + { id: guild.roles.everyone.id, deny: [PermissionFlagsBits.ViewChannel] }, + { id: role.id, allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory, PermissionFlagsBits.Connect, PermissionFlagsBits.Speak] }, + { id: guild.members.me.id, allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.ManageChannels, PermissionFlagsBits.ManageMessages, PermissionFlagsBits.Connect, PermissionFlagsBits.Speak] }, + ]; + category = await guild.channels.create({ name: `Clan • ${name}`.slice(0, 100), type: ChannelType.GuildCategory, permissionOverwrites: overwrites, reason: `Clan category for ${name}` }); + textChannel = await guild.channels.create({ name: 'chat', type: ChannelType.GuildText, parent: category.id, permissionOverwrites: overwrites, reason: `Clan text channel for ${name}` }); + voiceChannel = await guild.channels.create({ name: 'Voice', type: ChannelType.GuildVoice, parent: category.id, permissionOverwrites: overwrites, reason: `Clan voice channel for ${name}` }); + await owner.roles.add(role, `Clan owner: ${name}`); + return { role, category, textChannel, voiceChannel }; + } catch (error) { + await voiceChannel?.delete().catch(() => {}); + await textChannel?.delete().catch(() => {}); + await category?.delete().catch(() => {}); + await role.delete().catch(() => {}); + throw error; + } +} + +export default { + data: clanCommand, + category: 'Community', + async execute(interaction, config, client) { + if (!interaction.inGuild()) return interaction.reply({ content: 'This command can only be used in a server.', ephemeral: true }); + try { + const group = interaction.options.getSubcommandGroup(false); + const sub = interaction.options.getSubcommand(); + + if (group === 'admin') { + if (!adminAllowed(interaction)) return interaction.reply({ content: 'You need **Manage Server** permission to use clan administration.', ephemeral: true }); + + if (sub === 'list') { + const clans = await getClans(client, interaction.guildId); + if (!clans.length) return interaction.reply({ content: 'No clans exist in this server.', ephemeral: true }); + return interaction.reply({ embeds: [new EmbedBuilder().setTitle('Server Clans').setDescription(clans.map((item, index) => `${index + 1}. **${item.name}** — <@${item.ownerId}> — \`${item.id}\``).join('\n')).setTimestamp()] }); + } + + if (sub === 'create') { + const owner = interaction.options.getMember('owner'); + const name = sanitizeClanName(interaction.options.getString('name', true)); + if (!owner || !name) return interaction.reply({ content: 'Invalid clan owner or name.', ephemeral: true }); + const existing = await getClanForUser(client, interaction.guildId, owner.id); + if (existing) return interaction.reply({ content: `That user already belongs to **${existing.name}**.`, ephemeral: true }); + const clans = await getClans(client, interaction.guildId); + if (clans.some((clan) => clan.name.toLowerCase() === name.toLowerCase())) return interaction.reply({ content: 'A clan with that name already exists.', ephemeral: true }); + + const resources = await createClanResources(interaction, owner, name); + try { + const clan = await createClan(client, interaction.guildId, { id: makeClanId(), name, ownerId: owner.id, roleId: resources.role.id, categoryId: resources.category.id, textChannelId: resources.textChannel.id, voiceChannelId: resources.voiceChannel.id, memberIds: [] }); + await resources.textChannel.send(`Welcome to **${name}**!\nOwner: <@${owner.id}>\nUse \`/clan members\` and \`/clan add\` to manage your clan.`).catch(() => {}); + return interaction.reply({ embeds: [new EmbedBuilder().setTitle('Clan Created').setDescription(`**${name}** has been created for ${owner}.\n\n${resources.textChannel}\n${resources.voiceChannel}\nRole: ${resources.role}\nClan ID: \`${clan.id}\``).setTimestamp()] }); + } catch (error) { + await resources.voiceChannel.delete().catch(() => {}); + await resources.textChannel.delete().catch(() => {}); + await resources.category.delete().catch(() => {}); + await resources.role.delete().catch(() => {}); + throw error; + } + } + + const clanId = interaction.options.getString('clan_id', true); + const clan = await getClan(client, interaction.guildId, clanId); + if (!clan) return interaction.reply({ content: 'Clan not found.', ephemeral: true }); + if (sub === 'info') return interaction.reply({ embeds: [clanEmbed(clan)] }); + if (sub === 'delete') { + for (const channelId of [clan.voiceChannelId, clan.textChannelId, clan.categoryId]) { + const channel = await interaction.guild.channels.fetch(channelId).catch(() => null); + await channel?.delete(`Deleting clan ${clan.name}`).catch(() => {}); + } + const role = await interaction.guild.roles.fetch(clan.roleId).catch(() => null); + await role?.delete(`Deleting clan ${clan.name}`).catch(() => {}); + await deleteClan(client, interaction.guildId, clan.id); + return interaction.reply({ content: `Deleted clan **${clan.name}** and its role/channels.`, ephemeral: true }); + } + } + + const clan = await getClanForUser(client, interaction.guildId, interaction.user.id); + if (!clan) return interaction.reply({ content: 'You are not a member of a clan.', ephemeral: true }); + if (sub === 'info') return interaction.reply({ embeds: [clanEmbed(clan)] }); + + if (sub === 'members') { + const ids = [clan.ownerId, ...clan.memberIds]; + const members = await Promise.all(ids.map(async (id) => { + const member = await interaction.guild.members.fetch(id).catch(() => null); + return member ? `${member} — ${id === clan.ownerId ? 'Owner' : 'Member'}` : `<@${id}>`; + })); + return interaction.reply({ embeds: [new EmbedBuilder().setTitle(`${clan.name} Members`).setDescription(members.join('\n') || 'No members.').setTimestamp()] }); + } + + if (clan.ownerId !== interaction.user.id) return interaction.reply({ content: 'Only the clan owner can manage members or rename the clan.', ephemeral: true }); + + if (sub === 'add') { + const user = interaction.options.getUser('user', true); + if (user.bot) return interaction.reply({ content: 'Bots cannot be added to clans.', ephemeral: true }); + const currentClan = await getClanForUser(client, interaction.guildId, user.id); + if (currentClan) return interaction.reply({ content: `That user already belongs to **${currentClan.name}**.`, ephemeral: true }); + const member = await interaction.guild.members.fetch(user.id).catch(() => null); + const role = await interaction.guild.roles.fetch(clan.roleId).catch(() => null); + if (!member || !role) return interaction.reply({ content: 'Could not find the member or clan role.', ephemeral: true }); + await member.roles.add(role, `Added to clan ${clan.name}`); + await addClanMember(client, interaction.guildId, clan.id, user.id); + return interaction.reply({ content: `${user} was added to **${clan.name}**.` }); + } + + if (sub === 'remove') { + const user = interaction.options.getUser('user', true); + if (user.id === clan.ownerId) return interaction.reply({ content: 'The clan owner cannot be removed.', ephemeral: true }); + if (!clan.memberIds.includes(user.id)) return interaction.reply({ content: 'That user is not in your clan.', ephemeral: true }); + await removeClanMember(client, interaction.guildId, clan.id, user.id); + const member = await interaction.guild.members.fetch(user.id).catch(() => null); + const role = await interaction.guild.roles.fetch(clan.roleId).catch(() => null); + await member?.roles.remove(role).catch(() => {}); + return interaction.reply({ content: `${user} was removed from **${clan.name}**.` }); + } + + if (sub === 'rename') { + const name = sanitizeClanName(interaction.options.getString('name', true)); + if (!name) return interaction.reply({ content: 'Invalid clan name.', ephemeral: true }); + const clans = await getClans(client, interaction.guildId); + if (clans.some((item) => item.id !== clan.id && item.name.toLowerCase() === name.toLowerCase())) return interaction.reply({ content: 'A clan with that name already exists.', ephemeral: true }); + await updateClan(client, interaction.guildId, clan.id, { name }); + const role = await interaction.guild.roles.fetch(clan.roleId).catch(() => null); + await role?.setName(name).catch(() => {}); + const category = await interaction.guild.channels.fetch(clan.categoryId).catch(() => null); + await category?.setName(`Clan • ${name}`.slice(0, 100)).catch(() => {}); + return interaction.reply({ content: `Your clan has been renamed to **${name}**.` }); + } + + return interaction.reply({ content: 'Unknown clan action.', ephemeral: true }); + } catch (error) { + return interaction.reply({ content: `Clan operation failed: ${error.message || 'Unknown error'}`, ephemeral: true }).catch(() => {}); + } + }, +}; diff --git a/src/commands/Community/role.js b/src/commands/Community/role.js new file mode 100644 index 0000000000..4637d3b94c --- /dev/null +++ b/src/commands/Community/role.js @@ -0,0 +1,83 @@ +import { + PermissionFlagsBits, + SlashCommandBuilder, +} from 'discord.js'; +import { withErrorHandling } from '../../utils/errorHandler.js'; + +function canManageRole(interaction, role) { + const me = interaction.guild.members.me; + if (!me) return false; + return role.editable && role.position < me.roles.highest.position; +} + +export default { + data: new SlashCommandBuilder() + .setName('role') + .setDescription('Manage member roles') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageRoles) + .addSubcommand((sub) => sub + .setName('add') + .setDescription('Give a role to a member') + .addUserOption((opt) => opt + .setName('user') + .setDescription('Member') + .setRequired(true)) + .addRoleOption((opt) => opt + .setName('role') + .setDescription('Role to give') + .setRequired(true))) + .addSubcommand((sub) => sub + .setName('remove') + .setDescription('Remove a role from a member') + .addUserOption((opt) => opt + .setName('user') + .setDescription('Member') + .setRequired(true)) + .addRoleOption((opt) => opt + .setName('role') + .setDescription('Role to remove') + .setRequired(true))), + category: 'Moderation', + execute: withErrorHandling(async (interaction) => { + if (!interaction.inGuild()) { + return interaction.reply({ content: 'This command can only be used in a server.', ephemeral: true }); + } + + const user = interaction.options.getUser('user', true); + const role = interaction.options.getRole('role', true); + const sub = interaction.options.getSubcommand(); + const member = await interaction.guild.members.fetch(user.id).catch(() => null); + + if (!member) { + return interaction.reply({ content: 'Member not found.', ephemeral: true }); + } + + if (role.managed) { + return interaction.reply({ content: 'This role is managed by Discord and cannot be manually assigned.', ephemeral: true }); + } + + if (!canManageRole(interaction, role)) { + return interaction.reply({ content: 'I cannot manage this role because it is above or equal to my highest role.', ephemeral: true }); + } + + if (sub === 'add') { + if (member.roles.cache.has(role.id)) { + return interaction.reply({ content: `${user} already has **${role.name}**.`, ephemeral: true }); + } + + await member.roles.add(role, `Role added by ${interaction.user.tag}`); + return interaction.reply({ + content: `Added **${role.name}** to ${user}.\nBy: ${interaction.user}`, + }); + } + + if (!member.roles.cache.has(role.id)) { + return interaction.reply({ content: `${user} does not have **${role.name}**.`, ephemeral: true }); + } + + await member.roles.remove(role, `Role removed by ${interaction.user.tag}`); + return interaction.reply({ + content: `Removed **${role.name}** from ${user}.\nBy: ${interaction.user}`, + }); + }), +}; diff --git a/src/commands/Community/shift.js b/src/commands/Community/shift.js new file mode 100644 index 0000000000..9344dc9d40 --- /dev/null +++ b/src/commands/Community/shift.js @@ -0,0 +1,107 @@ +import { + EmbedBuilder, + PermissionFlagsBits, + SlashCommandBuilder, +} from 'discord.js'; +import { getStaffData, recordStaffShift } from '../../services/staffService.js'; +import { + formatDuration, + getShiftData, + getShiftHistory, + getShiftLeaderboard, + getShiftStats, + startShift, + stopShift, + updateShiftConfig, +} from '../../services/staffShiftService.js'; +import { withErrorHandling } from '../../utils/errorHandler.js'; + +function hasManagerAccess(interaction, staffData) { + return interaction.member.permissions.has(PermissionFlagsBits.ManageGuild) + || Boolean(staffData.config.managerRoleId && interaction.member.roles.cache.has(staffData.config.managerRoleId)); +} + +function formatHours(hours) { + return `${Number(hours || 0).toFixed(2)}h`; +} + +export default { + data: new SlashCommandBuilder() + .setName('shift') + .setDescription('Manage staff shifts') + .addSubcommand((sub) => sub.setName('start').setDescription('Start your staff shift')) + .addSubcommand((sub) => sub.setName('stop').setDescription('Stop your current staff shift')) + .addSubcommand((sub) => sub.setName('status').setDescription('View a staff shift status').addUserOption((opt) => opt.setName('user').setDescription('Staff member').setRequired(false))) + .addSubcommand((sub) => sub.setName('history').setDescription('View staff shift history').addUserOption((opt) => opt.setName('user').setDescription('Staff member').setRequired(false)).addIntegerOption((opt) => opt.setName('limit').setDescription('Number of shifts to show').setMinValue(1).setMaxValue(20))) + .addSubcommand((sub) => sub.setName('leaderboard').setDescription('View the staff shift leaderboard')) + .addSubcommand((sub) => sub.setName('setup').setDescription('Configure staff shift requirements').addNumberOption((opt) => opt.setName('minimum_hours').setDescription('Minimum required shift hours').setMinValue(0).setMaxValue(1000).setRequired(true))), + category: 'Community', + execute: withErrorHandling(async (interaction) => { + if (!interaction.inGuild()) return interaction.reply({ content: 'This command can only be used in a server.', ephemeral: true }); + + const sub = interaction.options.getSubcommand(); + const staffData = await getStaffData(interaction.guildId); + const shiftData = await getShiftData(interaction.guildId); + + if (sub === 'setup') { + if (!hasManagerAccess(interaction, staffData)) return interaction.reply({ content: 'You do not have permission to configure staff shifts.', ephemeral: true }); + const minimumHours = interaction.options.getNumber('minimum_hours', true); + const updated = await updateShiftConfig(interaction.guildId, { minimumHours }); + return interaction.reply({ content: `Staff shift settings saved. Minimum hours: **${Number(updated.config.minimumHours).toFixed(2)}h**.`, ephemeral: true }); + } + + const requestedUser = interaction.options.getUser('user'); + const targetUser = requestedUser || interaction.user; + const isSelf = targetUser.id === interaction.user.id; + const managerAccess = hasManagerAccess(interaction, staffData); + + if (!isSelf && !managerAccess) return interaction.reply({ content: 'You can only view your own shift information.', ephemeral: true }); + + if (sub === 'start') { + if (!staffData.members[targetUser.id]) return interaction.reply({ content: 'You are not registered in the staff system yet. Open your staff profile first.', ephemeral: true }); + const result = await startShift(interaction.guildId, targetUser.id); + if (!result.started) return interaction.reply({ content: `You already have an active shift since .`, ephemeral: true }); + return interaction.reply({ content: `Shift started at .`, ephemeral: true }); + } + + if (sub === 'stop') { + const result = await stopShift(interaction.guildId, targetUser.id); + if (!result.stopped) return interaction.reply({ content: 'You do not have an active shift.', ephemeral: true }); + await recordStaffShift(interaction.guildId, targetUser.id, result.shift.durationHours); + const minimum = Number(shiftData.config.minimumHours || 0); + const duration = formatDuration(result.shift.durationMs); + const requirement = minimum > 0 ? `\nMinimum: **${minimum.toFixed(2)}h** — ${result.shift.durationHours >= minimum ? 'Met' : 'Not met'}` : ''; + return interaction.reply({ content: `Shift stopped. Duration: **${duration}**.${requirement}`, ephemeral: true }); + } + + if (sub === 'status') { + const stats = getShiftStats(shiftData, targetUser.id); + const embed = new EmbedBuilder() + .setTitle(`Shift Status — ${targetUser.username}`) + .setThumbnail(targetUser.displayAvatarURL()) + .addFields( + { name: 'Status', value: stats.active ? `🟢 Active\nStarted ` : '⚪ Offline', inline: true }, + { name: 'Total Hours', value: `**${formatHours(stats.totalHours)}**`, inline: true }, + { name: 'Completed Shifts', value: `**${stats.shiftCount}**`, inline: true }, + { name: 'Minimum Hours', value: `**${Number(shiftData.config.minimumHours || 0).toFixed(2)}h**`, inline: true }, + ); + return interaction.reply({ embeds: [embed] }); + } + + if (sub === 'history') { + const limit = interaction.options.getInteger('limit') || 10; + const history = await getShiftHistory(interaction.guildId, targetUser.id, limit); + if (!history.length) return interaction.reply({ content: `No completed shifts found for ${targetUser}.`, ephemeral: true }); + const lines = history.map((shift, index) => `${index + 1}. — **${formatDuration(shift.durationMs)}** — to `); + return interaction.reply({ embeds: [new EmbedBuilder().setTitle(`Shift History — ${targetUser.username}`).setDescription(lines.join('\n')).setFooter({ text: `Showing ${history.length} shift(s)` })] }); + } + + if (sub === 'leaderboard') { + if (!managerAccess) return interaction.reply({ content: 'You do not have permission to view the staff shift leaderboard.', ephemeral: true }); + const leaderboard = getShiftLeaderboard(shiftData, 10); + if (!leaderboard.length) return interaction.reply({ content: 'No staff shift data exists yet.', ephemeral: true }); + const lines = leaderboard.map((entry, index) => `${index + 1}. <@${entry.userId}> — **${formatHours(entry.stats.totalHours)}** — ${entry.stats.shiftCount} completed shift(s)${entry.stats.active ? ' 🟢' : ''}`); + return interaction.reply({ embeds: [new EmbedBuilder().setTitle('Staff Shift Leaderboard').setDescription(lines.join('\n')).addFields({ name: 'Minimum Hours', value: `**${Number(shiftData.config.minimumHours || 0).toFixed(2)}h**` })] }); + } + }), +}; diff --git a/src/commands/Community/staff.js b/src/commands/Community/staff.js new file mode 100644 index 0000000000..07c61bbb2a --- /dev/null +++ b/src/commands/Community/staff.js @@ -0,0 +1,197 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + ChannelType, + EmbedBuilder, + PermissionFlagsBits, + SlashCommandBuilder, +} from 'discord.js'; +import { + calculateActivityScore, + countWarnings, + getStaffData, + getStaffLeaderboard, + getStaffProfile, + updateStaffConfig, + addStaffWarning, + addPromotion, + addDemotion, + addStaffNote, + resetStaffProfile, +} from '../../services/staffService.js'; +import { withErrorHandling } from '../../utils/errorHandler.js'; + +const dashboardButtons = () => [ + new ActionRowBuilder().addComponents( + new ButtonBuilder().setCustomId('staff_my_profile').setLabel('My Profile').setStyle(ButtonStyle.Primary), + new ButtonBuilder().setCustomId('staff_activity').setLabel('Activity').setStyle(ButtonStyle.Secondary), + new ButtonBuilder().setCustomId('staff_list').setLabel('Staff List').setStyle(ButtonStyle.Secondary), + ), + new ActionRowBuilder().addComponents( + new ButtonBuilder().setCustomId('staff_warnings').setLabel('Warnings').setStyle(ButtonStyle.Secondary), + new ButtonBuilder().setCustomId('staff_promotions').setLabel('Promotions').setStyle(ButtonStyle.Secondary), + new ButtonBuilder().setCustomId('staff_demotions').setLabel('Demotions').setStyle(ButtonStyle.Secondary), + new ButtonBuilder().setCustomId('staff_notes').setLabel('Notes').setStyle(ButtonStyle.Secondary), + ), +]; + +function dashboardEmbed(guild, data) { + const count = Object.keys(data.members).length; + const warned = Object.values(data.members).filter((member) => countWarnings(member) > 0).length; + return new EmbedBuilder() + .setTitle('Staff Management') + .setDescription(`**${guild.name}**\nCentralized staff management, activity and history.`) + .addFields( + { name: 'Staff', value: `**${count}**`, inline: true }, + { name: 'With Warnings', value: `**${warned}**`, inline: true }, + { name: 'Review Threshold', value: `**${data.config.warningsBeforeReview}** warnings`, inline: true }, + ) + .setFooter({ text: 'Use /staff profile, /staff warn, /staff promote or /staff demote to manage staff.' }); +} + +export default { + data: new SlashCommandBuilder() + .setName('staff') + .setDescription('Manage server staff') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) + .addSubcommand((sub) => sub.setName('dashboard').setDescription('Open the staff dashboard')) + .addSubcommand((sub) => sub.setName('leaderboard').setDescription('View staff performance leaderboard')) + .addSubcommand((sub) => sub + .setName('profile') + .setDescription('View a staff profile') + .addUserOption((opt) => opt.setName('user').setDescription('Staff member').setRequired(true))) + .addSubcommand((sub) => sub + .setName('warn') + .setDescription('Issue a staff warning') + .addUserOption((opt) => opt.setName('user').setDescription('Staff member').setRequired(true)) + .addStringOption((opt) => opt.setName('reason').setDescription('Reason').setRequired(true).setMaxLength(500))) + .addSubcommand((sub) => sub + .setName('promote') + .setDescription('Promote a staff member') + .addUserOption((opt) => opt.setName('user').setDescription('Staff member').setRequired(true)) + .addRoleOption((opt) => opt.setName('new_role').setDescription('Role to give').setRequired(true)) + .addRoleOption((opt) => opt.setName('remove_role').setDescription('Role to remove').setRequired(true)) + .addStringOption((opt) => opt.setName('reason').setDescription('Reason').setRequired(true).setMaxLength(500)) + .addBooleanOption((opt) => opt.setName('reset_profile').setDescription('Reset current profile data after the promotion').setRequired(true))) + .addSubcommand((sub) => sub + .setName('demote') + .setDescription('Demote a staff member') + .addUserOption((opt) => opt.setName('user').setDescription('Staff member').setRequired(true)) + .addRoleOption((opt) => opt.setName('new_role').setDescription('Role to give').setRequired(true)) + .addRoleOption((opt) => opt.setName('remove_role').setDescription('Role to remove').setRequired(true)) + .addStringOption((opt) => opt.setName('reason').setDescription('Reason').setRequired(true).setMaxLength(500)) + .addBooleanOption((opt) => opt.setName('reset_profile').setDescription('Reset current profile data after the demotion').setRequired(true))) + .addSubcommand((sub) => sub + .setName('note') + .setDescription('Add a private staff note') + .addUserOption((opt) => opt.setName('user').setDescription('Staff member').setRequired(true)) + .addStringOption((opt) => opt.setName('note').setDescription('Internal note').setRequired(true).setMaxLength(1000))) + .addSubcommand((sub) => sub + .setName('setup') + .setDescription('Configure staff channels and permissions') + .addChannelOption((opt) => opt.setName('promotion_channel').setDescription('Promotion announcement channel').addChannelTypes(ChannelType.GuildText)) + .addChannelOption((opt) => opt.setName('demotion_channel').setDescription('Demotion announcement channel').addChannelTypes(ChannelType.GuildText)) + .addChannelOption((opt) => opt.setName('warning_channel').setDescription('Staff warning channel').addChannelTypes(ChannelType.GuildText)) + .addRoleOption((opt) => opt.setName('manager_role').setDescription('Role allowed to manage staff')) + .addIntegerOption((opt) => opt.setName('warnings_before_review').setDescription('Warnings before review').setMinValue(1).setMaxValue(20))), + category: 'Community', + execute: withErrorHandling(async (interaction) => { + if (!interaction.inGuild()) return interaction.reply({ content: 'This command can only be used in a server.', ephemeral: true }); + const sub = interaction.options.getSubcommand(); + const data = await getStaffData(interaction.guildId); + + if (sub === 'dashboard') return interaction.reply({ embeds: [dashboardEmbed(interaction.guild, data)], components: dashboardButtons() }); + + if (sub === 'leaderboard') { + const leaderboard = getStaffLeaderboard(data, 10); + if (!leaderboard.length) return interaction.reply({ content: 'No staff performance data exists yet.', ephemeral: true }); + const lines = leaderboard.map((entry, index) => { + const activity = entry.profile?.activity || {}; + return `${index + 1}. <@${entry.userId}> — **${entry.score}/100**\n 💬 ${Number(activity.messages || 0).toLocaleString()} messages • 🎫 ${Number(activity.ticketsHandled || 0)} tickets • ⏱️ ${Number(activity.shiftHours || 0).toFixed(2)}h shifts • ⚠️ ${countWarnings(entry.profile)} warnings`; + }); + return interaction.reply({ embeds: [new EmbedBuilder().setTitle('Staff Performance Leaderboard').setDescription(lines.join('\n\n')).setFooter({ text: 'Performance score combines staff activity and completed shift hours.' })] }); + } + + if (sub === 'setup') { + const patch = {}; + const promotionChannel = interaction.options.getChannel('promotion_channel'); + const demotionChannel = interaction.options.getChannel('demotion_channel'); + const warningChannel = interaction.options.getChannel('warning_channel'); + const managerRole = interaction.options.getRole('manager_role'); + const threshold = interaction.options.getInteger('warnings_before_review'); + if (promotionChannel) patch.promotionChannelId = promotionChannel.id; + if (demotionChannel) patch.demotionChannelId = demotionChannel.id; + if (warningChannel) patch.warningChannelId = warningChannel.id; + if (managerRole) patch.managerRoleId = managerRole.id; + if (threshold) patch.warningsBeforeReview = threshold; + if (!Object.keys(patch).length) return interaction.reply({ content: 'No settings were supplied.', ephemeral: true }); + const updated = await updateStaffConfig(interaction.guildId, patch); + return interaction.reply({ content: `Staff settings saved.\nPromotion: ${updated.config.promotionChannelId ? `<#${updated.config.promotionChannelId}>` : 'Not set'}\nDemotion: ${updated.config.demotionChannelId ? `<#${updated.config.demotionChannelId}>` : 'Not set'}\nWarnings: ${updated.config.warningChannelId ? `<#${updated.config.warningChannelId}>` : 'Not set'}\nManager role: ${updated.config.managerRoleId ? `<@&${updated.config.managerRoleId}>` : 'Not set'}`, ephemeral: true }); + } + + const user = interaction.options.getUser('user'); + if (!user) return interaction.reply({ content: 'A staff member is required.', ephemeral: true }); + + if (sub === 'profile') { + const member = await interaction.guild.members.fetch(user.id).catch(() => null); + const profile = await getStaffProfile(interaction.guildId, user.id, { joinedAt: member?.joinedAt?.toISOString() }); + return interaction.reply({ embeds: [new EmbedBuilder() + .setTitle('Staff Profile') + .setDescription(`**${user}**\n${member?.roles?.highest ? `Current Role: **${member.roles.highest.name}**` : ''}`) + .addFields( + { name: 'Performance', value: `**${calculateActivityScore(profile)}/100**`, inline: true }, + { name: 'Activity', value: `**${Number(profile.activity?.messages || 0).toLocaleString()} messages**`, inline: true }, + { name: 'Warnings', value: `**${countWarnings(profile)}**`, inline: true }, + { name: 'Moderation Actions', value: `**${profile.activity?.moderationActions || 0}**`, inline: true }, + { name: 'Tickets Handled', value: `**${profile.activity?.ticketsHandled || 0}**`, inline: true }, + { name: 'Shift Hours', value: `**${Number(profile.activity?.shiftHours || 0).toFixed(2)}h**`, inline: true }, + { name: 'Shifts', value: `**${profile.activity?.shiftCount || 0}**`, inline: true }, + { name: 'Promotions', value: `**${profile.promotions.length}**`, inline: true }, + { name: 'Demotions', value: `**${profile.demotions.length}**`, inline: true }, + ) + .setThumbnail(user.displayAvatarURL())] }); + } + + if (sub === 'warn') { + const reason = interaction.options.getString('reason', true); + await addStaffWarning(interaction.guildId, user.id, interaction.user.id, reason); + const profile = await getStaffProfile(interaction.guildId, user.id); + const channelId = data.config.warningChannelId; + if (channelId) { + const channel = await interaction.guild.channels.fetch(channelId).catch(() => null); + if (channel?.isTextBased()) await channel.send(`⚠️ **Staff Warning**\n${user}\nReason: ${reason}\nIssued by: ${interaction.user}\nWarnings: **${profile.warnings.length}/${data.config.warningsBeforeReview}**`); + } + return interaction.reply({ content: `Staff warning issued to ${user}. Total warnings: **${profile.warnings.length}**.`, ephemeral: true }); + } + + const newRole = interaction.options.getRole('new_role'); + const removeRole = interaction.options.getRole('remove_role'); + const reason = interaction.options.getString('reason', true); + if (!newRole || !removeRole) return interaction.reply({ content: 'Both roles are required.', ephemeral: true }); + const target = await interaction.guild.members.fetch(user.id); + if (newRole.position >= interaction.guild.members.me.roles.highest.position || removeRole.position >= interaction.guild.members.me.roles.highest.position) return interaction.reply({ content: 'I cannot manage one of these roles because it is above my highest role.', ephemeral: true }); + + if (sub === 'promote' || sub === 'demote') { + const resetProfile = interaction.options.getBoolean('reset_profile', true); + await target.roles.remove(removeRole).catch(() => null); + await target.roles.add(newRole); + const record = { fromRoleId: removeRole.id, fromRoleName: removeRole.name, toRoleId: newRole.id, toRoleName: newRole.name, reason, issuerId: interaction.user.id }; + if (sub === 'promote') await addPromotion(interaction.guildId, user.id, record); + else await addDemotion(interaction.guildId, user.id, record); + if (resetProfile) await resetStaffProfile(interaction.guildId, user.id); + const channelId = sub === 'promote' ? data.config.promotionChannelId : data.config.demotionChannelId; + if (channelId) { + const channel = await interaction.guild.channels.fetch(channelId).catch(() => null); + if (channel?.isTextBased()) await channel.send(`${sub === 'promote' ? '📈' : '📉'} **Staff ${sub === 'promote' ? 'Promotion' : 'Demotion'}**\n${user}\n**${removeRole.name}** → **${newRole.name}**\nReason: ${reason}\nBy: ${interaction.user}`); + } + return interaction.reply({ content: `${user} has been ${sub === 'promote' ? 'promoted' : 'demoted'}: **${removeRole.name}** → **${newRole.name}**.${resetProfile ? ' Profile data has been reset; promotion/demotion history was preserved.' : ''}`, ephemeral: true }); + } + + if (sub === 'note') { + const note = interaction.options.getString('note', true); + await addStaffNote(interaction.guildId, user.id, interaction.user.id, note); + return interaction.reply({ content: `Private staff note added for ${user}.`, ephemeral: true }); + } + }), +}; diff --git a/src/commands/Economy/balance.js b/src/commands/Economy/balance.js deleted file mode 100644 index 95528ecd9c..0000000000 --- a/src/commands/Economy/balance.js +++ /dev/null @@ -1,87 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, getMaxBankCapacity } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -export default { - data: new SlashCommandBuilder() - .setName('balance') - .setDescription("Check your or someone else's balance") - .addUserOption(option => - option - .setName('user') - .setDescription('User to check balance for') - .setRequired(false) - ), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userOption = interaction.options.getUser("user"); - const targetUser = userOption || interaction.user; - const guildId = interaction.guildId; - - logger.info(`[ECONOMY] Balance check - userOption: ${userOption?.id || 'null'}, targetUser: ${targetUser.id}, guildId: ${guildId}, isPrefix: ${!!interaction._commandStartTime}`); - - logger.debug(`[ECONOMY] Balance check for ${targetUser.id}`, { userId: targetUser.id, guildId }); - - if (targetUser.bot) { - throw createError( - "Bot user queried for balance", - ErrorTypes.VALIDATION, - "Bots don't have an economy balance." - ); - } - - const userData = await getEconomyData(client, guildId, targetUser.id); - - logger.info(`[ECONOMY] Economy data retrieved - userData:`, userData); - - if (!userData) { - throw createError( - "Failed to load economy data", - ErrorTypes.DATABASE, - "Failed to load economy data. Please try again later.", - { userId: targetUser.id, guildId } - ); - } - - const maxBank = getMaxBankCapacity(userData); - - const wallet = typeof userData.wallet === 'number' ? userData.wallet : 0; - const bank = typeof userData.bank === 'number' ? userData.bank : 0; - - const embed = createEmbed({ - title: `${targetUser.username}'s Balance`, - description: `Here is the current financial status for ${targetUser.username}.`, - }) - .addFields( - { - name: "💵 Cash", - value: `$${wallet.toLocaleString()}`, - inline: true, - }, - { - name: "🏦 Bank", - value: `$${bank.toLocaleString()} / $${maxBank.toLocaleString()}`, - inline: true, - }, - { - name: "💰 Total", - value: `$${(wallet + bank).toLocaleString()}`, - inline: true, - } - ) - .setFooter({ - text: `Requested by ${interaction.user.tag}`, - iconURL: interaction.user.displayAvatarURL(), - }); - - logger.info(`[ECONOMY] Balance retrieved`, { userId: targetUser.id, wallet, bank }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - }, { command: 'balance' }) -}; \ No newline at end of file diff --git a/src/commands/Economy/beg.js b/src/commands/Economy/beg.js deleted file mode 100644 index 9b2fc717f9..0000000000 --- a/src/commands/Economy/beg.js +++ /dev/null @@ -1,99 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { successEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import { botConfig } from '../../config/bot.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -const COOLDOWN = 30 * 60 * 1000; -const MIN_WIN = Number(botConfig?.economy?.begMin) || 50; -const MAX_WIN = Number(botConfig?.economy?.begMax) || 200; -const SUCCESS_CHANCE = 0.7; - -export default { - data: new SlashCommandBuilder() - .setName('beg') - .setDescription('Beg for a small amount of money'), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userId = interaction.user.id; - const guildId = interaction.guildId; - - let userData = await getEconomyData(client, guildId, userId); - - if (!userData) { - throw createError( - "Failed to load economy data", - ErrorTypes.DATABASE, - "Failed to load your economy data. Please try again later.", - { userId, guildId } - ); - } - - const lastBeg = userData.lastBeg || 0; - const remainingTime = lastBeg + COOLDOWN - Date.now(); - - if (remainingTime > 0) { - const minutes = Math.floor(remainingTime / 60000); - const seconds = Math.floor((remainingTime % 60000) / 1000); - - let timeMessage = - minutes > 0 ? `${minutes} minute(s)` : `${seconds} second(s)`; - - throw createError( - "Beg cooldown active", - ErrorTypes.RATE_LIMIT, - `You are tired from begging! Try again in **${timeMessage}**.`, - { remainingTime, minutes, seconds, cooldownType: 'beg' } - ); - } - - const success = Math.random() < SUCCESS_CHANCE; - - let replyEmbed; - let newCash = userData.wallet; - - if (success) { - const amountWon = - Math.floor(Math.random() * (MAX_WIN - MIN_WIN + 1)) + MIN_WIN; - - newCash += amountWon; - - const successMessages = [ - `A kind stranger drops **$${amountWon.toLocaleString()}** into your cup.`, - `You spotted an unattended wallet! You grab **$${amountWon.toLocaleString()}** and run.`, - `Someone took pity on you and gave you **$${amountWon.toLocaleString()}**!`, - `You found **$${amountWon.toLocaleString()}** under a park bench.`, - ]; - - replyEmbed = successEmbed( - 'Begging Successful', - successMessages[ - Math.floor(Math.random() * successMessages.length) - ] - ); - } else { - const failMessages = [ - "The police chased you off. You got nothing.", - "Someone yelled, 'Get a job!' and walked past.", - "A squirrel stole the single coin you had.", - "You tried to beg, but you were too embarrassed and gave up.", - ]; - - replyEmbed = warningEmbed( - 'Insufficient Funds', - failMessages[Math.floor(Math.random() * failMessages.length)] - ); - } - - userData.wallet = newCash; -userData.lastBeg = Date.now(); - - await setEconomyData(client, guildId, userId, userData); - - await InteractionHelper.safeEditReply(interaction, { embeds: [replyEmbed] }); - }, { command: 'beg' }) -}; \ No newline at end of file diff --git a/src/commands/Economy/buy.js b/src/commands/Economy/buy.js deleted file mode 100644 index 1a90387315..0000000000 --- a/src/commands/Economy/buy.js +++ /dev/null @@ -1,160 +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/config/guildConfig.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -const SHOP_ITEMS = shopItems; - -export default { - data: new SlashCommandBuilder() - .setName('buy') - .setDescription('Buy an item from the shop') - .addStringOption(option => - option - .setName('item_id') - .setDescription('ID of the item to buy') - .setRequired(true) - ) - .addIntegerOption(option => - option - .setName('quantity') - .setDescription('Quantity to buy (default: 1)') - .setRequired(false) - .setMinValue(1) - .setMaxValue(10) - ), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userId = interaction.user.id; - const guildId = interaction.guildId; - const itemId = interaction.options.getString("item_id").toLowerCase(); - const quantity = interaction.options.getInteger("quantity") || 1; - - const item = SHOP_ITEMS.find(i => i.id === itemId); - - if (!item) { - throw createError( - `Item ${itemId} not found`, - ErrorTypes.VALIDATION, - `The item ID \`${itemId}\` does not exist in the shop.`, - { itemId } - ); - } - - if (quantity < 1) { - throw createError( - "Invalid quantity", - ErrorTypes.VALIDATION, - "You must purchase a quantity of 1 or more.", - { quantity } - ); - } - - const totalCost = item.price * quantity; - - const guildConfig = await getGuildConfig(client, guildId); - const PREMIUM_ROLE_ID = guildConfig.premiumRoleId; - - const userData = await getEconomyData(client, guildId, userId); - - if (userData.wallet < totalCost) { - throw createError( - "Insufficient funds", - ErrorTypes.VALIDATION, - `You need **$${totalCost.toLocaleString()}** to purchase ${quantity}x **${item.name}**, but you only have **$${userData.wallet.toLocaleString()}** in cash.`, - { required: totalCost, current: userData.wallet, itemId, quantity } - ); - } - - if (item.type === "role" && itemId === "premium_role") { - if (!PREMIUM_ROLE_ID) { - throw createError( - "Premium role not configured", - ErrorTypes.CONFIGURATION, - "The **Premium Shop Role** has not been configured by a server administrator yet.", - { itemId } - ); - } - if (interaction.member.roles.cache.has(PREMIUM_ROLE_ID)) { - throw createError( - "Role already owned", - ErrorTypes.VALIDATION, - `You already have the **${item.name}** role.`, - { itemId, roleId: PREMIUM_ROLE_ID } - ); - } - if (quantity > 1) { - throw createError( - "Invalid quantity for role", - ErrorTypes.VALIDATION, - `You can only purchase the **${item.name}** role once.`, - { itemId, quantity } - ); - } - } - - userData.wallet -= totalCost; - - let successDescription = `You successfully purchased ${quantity}x **${item.name}** for **$${totalCost.toLocaleString()}**!`; - - if (item.type === "role" && itemId === "premium_role") { - const member = interaction.member; - - const role = interaction.guild.roles.cache.get(PREMIUM_ROLE_ID); - - if (!role) { - throw createError( - "Role not found", - ErrorTypes.CONFIGURATION, - "The configured premium role no longer exists in this guild.", - { roleId: PREMIUM_ROLE_ID } - ); - } - - try { - await member.roles.add( - role, - `Purchased role: ${item.name}`, - ); - successDescription += `\n\n**👑 The role ${role.toString()} has been granted to you!**`; - } catch (roleError) { - userData.wallet += totalCost; - await setEconomyData(client, guildId, userId, userData); - throw createError( - "Role assignment failed", - ErrorTypes.DISCORD_API, - "Successfully deducted money, but failed to grant the role. Your cash has been refunded.", - { roleId: PREMIUM_ROLE_ID, originalError: roleError.message } - ); - } - } else if (item.type === "upgrade") { - userData.upgrades[itemId] = true; - successDescription += `\n\n**✨ Your upgrade is now active!**`; - } else if (item.type === "consumable" || item.type === "tool") { - userData.inventory[itemId] = - (userData.inventory[itemId] || 0) + quantity; - if (item.type === "tool") { - successDescription += `\n\n**🛠️ ${item.name} added to your inventory!**`; - } - } - - await setEconomyData(client, guildId, userId, userData); - - const embed = successEmbed( - "💰 Purchase Successful", - successDescription, - ).addFields({ - name: "New Balance", - value: `$${userData.wallet.toLocaleString()}`, - inline: true, - }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed], flags: [MessageFlags.Ephemeral] }); - }, { command: 'buy' }) -}; \ No newline at end of file diff --git a/src/commands/Economy/crime.js b/src/commands/Economy/crime.js deleted file mode 100644 index 101dc5a575..0000000000 --- a/src/commands/Economy/crime.js +++ /dev/null @@ -1,119 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -const CRIME_COOLDOWN = 60 * 60 * 1000; -const JAIL_TIME = 2 * 60 * 60 * 1000; -const FINE_RATE = 0.2; - -const CRIME_TYPES = [ - { name: "Pickpocketing", min: 100, max: 500, risk: 0.3 }, - { name: "Burglary", min: 300, max: 1000, risk: 0.4 }, - { name: "Bank Heist", min: 1000, max: 5000, risk: 0.6 }, - { name: "Art Theft", min: 2000, max: 10000, risk: 0.7 }, - { name: "Cybercrime", min: 5000, max: 20000, risk: 0.8 }, -]; - -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 { - // Fine is based on the potential haul of the attempted crime - const potentialHaul = Math.floor((crime.min + crime.max) / 2); - const fine = Math.min(Math.floor(potentialHaul * FINE_RATE), userData.wallet || 0); - userData.wallet = Math.max(0, (userData.wallet || 0) - fine); - userData.jailedUntil = now + JAIL_TIME; - - await setEconomyData(client, guildId, userId, userData); - - const embed = warningEmbed( - "🚔 Crime Failed!", - `You were caught while attempting ${crime.name} and have been sent to jail! ` + - `You were fined ${fine.toLocaleString()} coins and will be in jail for 2 hours.` - ); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - } - }, { command: 'crime' }) -}; \ No newline at end of file diff --git a/src/commands/Economy/daily.js b/src/commands/Economy/daily.js deleted file mode 100644 index 4045d23ed0..0000000000 --- a/src/commands/Economy/daily.js +++ /dev/null @@ -1,104 +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/config/guildConfig.js'; -import { formatDuration } from '../../utils/embeds.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { botConfig } from '../../config/bot.js'; - -const DAILY_COOLDOWN = 24 * 60 * 60 * 1000; -const DAILY_AMOUNT = botConfig.economy?.dailyAmount ?? 100; -const PREMIUM_BONUS_PERCENTAGE = 0.1; - -export default { - data: new SlashCommandBuilder() - .setName('daily') - .setDescription('Claim your daily cash reward'), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userId = interaction.user.id; - const guildId = interaction.guildId; - const now = Date.now(); - - logger.debug(`[ECONOMY] Daily claimed started for ${userId}`, { userId, guildId }); - - const userData = await getEconomyData(client, guildId, userId); - - if (!userData) { - throw createError( - "Failed to load economy data for daily", - ErrorTypes.DATABASE, - "Failed to load your economy data. Please try again later.", - { userId, guildId } - ); - } - - const lastDaily = userData.lastDaily || 0; - - if (now < lastDaily + DAILY_COOLDOWN) { - const timeRemaining = lastDaily + DAILY_COOLDOWN - now; - throw createError( - "Daily cooldown active", - ErrorTypes.RATE_LIMIT, - `You need to wait before claiming daily again. Try again in **${formatDuration(timeRemaining)}**.`, - { timeRemaining, cooldownType: 'daily' } - ); - } - - const guildConfig = await getGuildConfig(client, guildId); - const PREMIUM_ROLE_ID = guildConfig.premiumRoleId; - - let earned = DAILY_AMOUNT; - let bonusMessage = ""; - let hasPremiumRole = false; - - if ( - PREMIUM_ROLE_ID && - interaction.member && - interaction.member.roles.cache.has(PREMIUM_ROLE_ID) - ) { - const bonusAmount = Math.floor( - DAILY_AMOUNT * PREMIUM_BONUS_PERCENTAGE, - ); - earned += bonusAmount; - bonusMessage = `\n✨ **Premium Bonus:** +$${bonusAmount.toLocaleString()}`; - hasPremiumRole = true; - } - - userData.wallet = (userData.wallet || 0) + earned; - userData.lastDaily = now; - - await setEconomyData(client, guildId, userId, userData); - - logger.info(`[ECONOMY_TRANSACTION] Daily claimed`, { - userId, - guildId, - amount: earned, - newWallet: userData.wallet, - hasPremium: hasPremiumRole, - timestamp: new Date().toISOString() - }); - - const embed = successEmbed( - "✅ Daily Claimed!", - `You have claimed your daily **$${earned.toLocaleString()}**!${bonusMessage}` - ) - .addFields({ - name: "New Cash Balance", - value: `$${userData.wallet.toLocaleString()}`, - inline: true, - }) - .setFooter({ - text: hasPremiumRole - ? `Next claim in 24 hours. (Premium Active)` - : `Next claim in 24 hours.`, - }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - }, { command: 'daily' }) -}; \ No newline at end of file diff --git a/src/commands/Economy/deposit.js b/src/commands/Economy/deposit.js deleted file mode 100644 index 56015b5fb4..0000000000 --- a/src/commands/Economy/deposit.js +++ /dev/null @@ -1,138 +0,0 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import { successEmbed, buildUserErrorEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData, getMaxBankCapacity } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -export default { - data: new SlashCommandBuilder() - .setName('deposit') - .setDescription('Deposit money from your wallet into your bank') - .addStringOption(option => - option - .setName('amount') - .setDescription('Amount to deposit (number or "all")') - .setRequired(true) - ), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userId = interaction.user.id; - const guildId = interaction.guildId; - const amountInput = interaction.options.getString("amount"); - - const userData = await getEconomyData(client, guildId, userId); - - if (!userData) { - throw createError( - "Failed to load economy data", - ErrorTypes.DATABASE, - "Failed to load your economy data. Please try again later.", - { userId, guildId } - ); - } - - const maxBank = getMaxBankCapacity(userData); - let depositAmount; - - if (amountInput.toLowerCase() === "all") { - depositAmount = userData.wallet; - } else { - depositAmount = parseInt(amountInput); - - if (isNaN(depositAmount) || depositAmount <= 0) { - throw createError( - "Invalid deposit amount", - ErrorTypes.VALIDATION, - `Please enter a valid number or 'all'. You entered: \`${amountInput}\``, - { amountInput, userId } - ); - } - } - - if (depositAmount === 0) { - throw createError( - "Zero deposit amount", - ErrorTypes.VALIDATION, - "You have no cash to deposit.", - { userId, walletBalance: userData.wallet } - ); - } - - if (depositAmount > userData.wallet) { - depositAmount = userData.wallet; - await interaction.followUp({ - embeds: [ - buildUserErrorEmbed( - 'validation', - `You tried to deposit more than you have. Depositing your remaining cash: **$${depositAmount.toLocaleString()}**` - ) - ], - flags: MessageFlags.Ephemeral, - }); - } - - const availableSpace = maxBank - userData.bank; - - if (availableSpace <= 0) { - throw createError( - "Bank is full", - ErrorTypes.VALIDATION, - `Your bank is currently full (Max Capacity: $${maxBank.toLocaleString()}). Purchase a **Bank Upgrade** to increase your limit.`, - { maxBank, currentBank: userData.bank, userId } - ); - } - - if (depositAmount > availableSpace) { - const originalDepositAmount = depositAmount; - depositAmount = availableSpace; - - if (amountInput.toLowerCase() !== "all") { - await interaction.followUp({ - embeds: [ - buildUserErrorEmbed( - 'validation', - `You only had space for **$${depositAmount.toLocaleString()}** in your bank account (Max: $${maxBank.toLocaleString()}). The rest remains in your cash.` - ) - ], - flags: MessageFlags.Ephemeral, - }); - } - } - - if (depositAmount === 0) { - throw createError( - "No space or cash for deposit", - ErrorTypes.VALIDATION, - "The amount you tried to deposit was either 0 or exceeded your bank capacity after checking your cash balance.", - { depositAmount, availableSpace, walletBalance: userData.wallet } - ); - } - - userData.wallet -= depositAmount; - userData.bank += depositAmount; - - await setEconomyData(client, guildId, userId, userData); - - const embed = successEmbed( - 'Deposit Successful', - `You successfully deposited **$${depositAmount.toLocaleString()}** into your bank.` - ) - .addFields( - { - name: "New Cash Balance", - value: `$${userData.wallet.toLocaleString()}`, - inline: true, - }, - { - name: "New Bank Balance", - value: `$${userData.bank.toLocaleString()} / $${maxBank.toLocaleString()}`, - inline: true, - }, - ); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - }, { command: 'deposit' }) -}; \ No newline at end of file diff --git a/src/commands/Economy/economy.js b/src/commands/Economy/economy.js deleted file mode 100644 index d702eb6157..0000000000 --- a/src/commands/Economy/economy.js +++ /dev/null @@ -1,32 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import economyDashboard from './modules/economy_dashboard.js'; - -export default { - slashOnly: true, - data: new SlashCommandBuilder() - .setName('economy') - .setDescription('Economy management commands') - .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) - .setDMPermission(false) - .addSubcommand(subcommand => - subcommand - .setName('dashboard') - .setDescription('Open the economy management dashboard') - ), - category: 'Economy', - - async execute(interaction, config, client) { - const deferred = await InteractionHelper.safeDefer(interaction, { - flags: MessageFlags.Ephemeral, - }); - if (!deferred) return; - - const subcommand = interaction.options.getSubcommand(); - - if (subcommand === 'dashboard') { - await economyDashboard.execute(interaction, config, client); - } - } -}; \ No newline at end of file diff --git a/src/commands/Economy/eleaderboard.js b/src/commands/Economy/eleaderboard.js deleted file mode 100644 index e4603797a7..0000000000 --- a/src/commands/Economy/eleaderboard.js +++ /dev/null @@ -1,89 +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'; -import { getEconomyPrefix } from '../../utils/database.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 = getEconomyPrefix(guildId); - - let allKeys = await client.db.list(prefix); - - if (!Array.isArray(allKeys)) { - allKeys = []; - } - - if (allKeys.length === 0) { - throw createError( - "No economy data found", - ErrorTypes.VALIDATION, - "No economy data found for this server." - ); - } - - let allUserData = []; - - for (const key of allKeys) { - const userId = key.replace(prefix, ""); - const userData = await client.db.get(key); - - if (userData) { - allUserData.push({ - userId: userId, - net_worth: (userData.wallet || 0) + (userData.bank || 0), - }); - } - } - - allUserData.sort((a, b) => b.net_worth - a.net_worth); - - const topUsers = allUserData.slice(0, 10); - const userRank = - allUserData.findIndex((u) => u.userId === interaction.user.id) + - 1; - const rankEmoji = ["🥇", "🥈", "🥉"]; - const leaderboardEntries = []; - - for (let i = 0; i < topUsers.length; i++) { - const user = topUsers[i]; - const rank = i + 1; - const emoji = rankEmoji[i] || `**#${rank}**`; - - leaderboardEntries.push( - `${emoji} <@${user.userId}> - 🏦 ${user.net_worth.toLocaleString()}`, - ); - } - - logger.info(`[ECONOMY] Leaderboard generated`, { - guildId, - userCount: allUserData.length, - userRank - }); - - const description = leaderboardEntries.length > 0 - ? leaderboardEntries.join("\n") - : "No economy data is available for this server yet."; - - const embed = createEmbed({ - title: `Economy Leaderboard`, - description, - footer: `Your Rank: ${userRank > 0 ?`#${userRank}`: "No ranking data available"}`, - }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - }, { command: 'eleaderboard' }) -}; \ No newline at end of file diff --git a/src/commands/Economy/fish.js b/src/commands/Economy/fish.js deleted file mode 100644 index 5288f048bb..0000000000 --- a/src/commands/Economy/fish.js +++ /dev/null @@ -1,132 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -const FISH_COOLDOWN = 45 * 60 * 1000; -const BASE_MIN_REWARD = 300; -const BASE_MAX_REWARD = 900; -const FISHING_ROD_MULTIPLIER = 1.5; - -const FISH_TYPES = [ - { name: 'Bass', emoji: '🐟', rarity: 'common' }, - { name: 'Salmon', emoji: '🐟', rarity: 'common' }, - { name: 'Trout', emoji: '🐟', rarity: 'common' }, - { name: 'Tuna', emoji: '🐠', rarity: 'uncommon' }, - { name: 'Swordfish', emoji: '🐠', rarity: 'uncommon' }, - { name: 'Octopus', emoji: '🐙', rarity: 'rare' }, - { name: 'Lobster', emoji: '🦞', rarity: 'rare' }, - { name: 'Shark', emoji: '🦈', rarity: 'epic' }, - { name: 'Whale', emoji: '🐋', rarity: 'legendary' }, -]; - -const CATCH_MESSAGES = [ - "You cast your line into the crystal clear waters...", - "You wait patiently as your bobber floats...", - "After a few minutes of waiting, you feel a tug...", - "The water ripples as something takes your bait...", - "You reel in your catch with expert precision...", -]; - -export default { - data: new SlashCommandBuilder() - .setName('fish') - .setDescription('Go fishing to catch fish and earn money'), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userId = interaction.user.id; - const guildId = interaction.guildId; - const now = Date.now(); - - const userData = await getEconomyData(client, guildId, userId); - const lastFish = userData.lastFish || 0; - const hasFishingRod = userData.inventory["fishing_rod"] || 0; - - if (now < lastFish + FISH_COOLDOWN) { - const remaining = lastFish + FISH_COOLDOWN - now; - const hours = Math.floor(remaining / (1000 * 60 * 60)); - const minutes = Math.floor( - (remaining % (1000 * 60 * 60)) / (1000 * 60), - ); - - throw createError( - "Fishing cooldown active", - ErrorTypes.RATE_LIMIT, - `You're too tired to fish right now. Rest for **${hours}h ${minutes}m** before fishing again.`, - { remaining, cooldownType: 'fish' } - ); - } - - const rand = Math.random(); - let fishCaught; - - if (rand < 0.5) { - - fishCaught = FISH_TYPES.filter(f => f.rarity === 'common')[Math.floor(Math.random() * 3)]; - } else if (rand < 0.75) { - - fishCaught = FISH_TYPES.filter(f => f.rarity === 'uncommon')[Math.floor(Math.random() * 2)]; - } else if (rand < 0.9) { - - fishCaught = FISH_TYPES.filter(f => f.rarity === 'rare')[Math.floor(Math.random() * 2)]; - } else if (rand < 0.98) { - - fishCaught = FISH_TYPES.find(f => f.rarity === 'epic'); - } else { - - fishCaught = FISH_TYPES.find(f => f.rarity === 'legendary'); - } - - const baseEarned = Math.floor( - Math.random() * (BASE_MAX_REWARD - BASE_MIN_REWARD + 1) - ) + BASE_MIN_REWARD; - - let finalEarned = baseEarned; - let multiplierMessage = ""; - - if (hasFishingRod > 0) { - finalEarned = Math.floor(baseEarned * FISHING_ROD_MULTIPLIER); - multiplierMessage = `\n🎣 **Fishing Rod Bonus: +50%**`; - } - - const catchMessage = CATCH_MESSAGES[Math.floor(Math.random() * CATCH_MESSAGES.length)]; - - userData.wallet += finalEarned; - userData.lastFish = now; - - await setEconomyData(client, guildId, userId, userData); - - const rarityColors = { - common: '#95A5A6', - uncommon: '#2ECC71', - rare: '#3498DB', - epic: '#9B59B6', - legendary: '#F1C40F' - }; - - const embed = createEmbed({ - title: 'Fishing Success!', - description: `${catchMessage}\n\nYou caught a **${fishCaught.emoji} ${fishCaught.name}**! You sold it for **$${finalEarned.toLocaleString()}**!${multiplierMessage}`, - color: rarityColors[fishCaught.rarity] - }) - .addFields( - { - name: "New Cash Balance", - value: `$${userData.wallet.toLocaleString()}`, - inline: true, - }, - { - name: "Rarity", - value: fishCaught.rarity.charAt(0).toUpperCase() + fishCaught.rarity.slice(1), - inline: true, - } - ) - .setFooter({ text: `Next fishing trip available in 45 minutes.` }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - }, { command: 'fish' }) -}; \ No newline at end of file diff --git a/src/commands/Economy/gamble.js b/src/commands/Economy/gamble.js deleted file mode 100644 index b4acf1745d..0000000000 --- a/src/commands/Economy/gamble.js +++ /dev/null @@ -1,131 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -const BASE_WIN_CHANCE = 0.4; -const CLOVER_WIN_BONUS = 0.1; -const CHARM_WIN_BONUS = 0.08; -const PAYOUT_MULTIPLIER = 2.0; -const GAMBLE_COOLDOWN = 5 * 60 * 1000; - -export default { - data: new SlashCommandBuilder() - .setName('gamble') - .setDescription('Gamble your money for a chance to win more') - .addIntegerOption(option => - option - .setName('amount') - .setDescription('Amount of cash to gamble') - .setRequired(true) - .setMinValue(1) - ), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userId = interaction.user.id; - const guildId = interaction.guildId; - const betAmount = interaction.options.getInteger("amount"); - const now = Date.now(); - - const userData = await getEconomyData(client, guildId, userId); - const lastGamble = userData.lastGamble || 0; - let cloverCount = userData.inventory["lucky_clover"] || 0; - let charmCount = userData.inventory["lucky_charm"] || 0; - - if (now < lastGamble + GAMBLE_COOLDOWN) { - const remaining = lastGamble + GAMBLE_COOLDOWN - now; - const minutes = Math.floor(remaining / (1000 * 60)); - const seconds = Math.floor((remaining % (1000 * 60)) / 1000); - - throw createError( - "Gamble cooldown active", - ErrorTypes.RATE_LIMIT, - `You need to cool down before gambling again. Wait **${minutes}m ${seconds}s**.`, - { remaining, cooldownType: 'gamble' } - ); - } - - if (userData.wallet < betAmount) { - throw createError( - "Insufficient cash for gamble", - ErrorTypes.VALIDATION, - `You only have $${userData.wallet.toLocaleString()} cash, but you are trying to bet $${betAmount.toLocaleString()}.`, - { required: betAmount, current: userData.wallet } - ); - } - - let winChance = BASE_WIN_CHANCE; - let cloverMessage = ""; - let usedClover = false; - let usedCharm = false; - - if (cloverCount > 0) { - winChance += CLOVER_WIN_BONUS; - userData.inventory["lucky_clover"] -= 1; - cloverMessage = `\n🍀 **Lucky Clover Consumed:** Your win chance was boosted!`; - usedClover = true; - } - - else if (charmCount > 0) { - winChance += CHARM_WIN_BONUS; - userData.inventory["lucky_charm"] -= 1; - cloverMessage = `\n🍀 **Lucky Charm Used (${charmCount - 1} uses remaining):** Your win chance was boosted!`; - usedCharm = true; - } - - const win = Math.random() < winChance; - let cashChange = 0; - let resultEmbed; - - if (win) { - const amountWon = Math.floor(betAmount * PAYOUT_MULTIPLIER); - // Net change: the bet is replaced by the payout (bet was at stake, not pre-deducted) - cashChange = amountWon - betAmount; - - resultEmbed = successEmbed( - "🎉 You Won!", - `You successfully gambled and turned your **$${betAmount.toLocaleString()}** bet into **$${amountWon.toLocaleString()}**!${cloverMessage}`, - ); - } else { -cashChange = -betAmount; - - resultEmbed = warningEmbed( - "💔 You Lost...", - `The dice rolled against you. You lost your **$${betAmount.toLocaleString()}** bet.`, - ); - } - - userData.wallet = (userData.wallet || 0) + cashChange; -userData.lastGamble = now; - - await setEconomyData(client, guildId, userId, userData); - - const newCash = userData.wallet; - - resultEmbed.addFields({ - name: "New Cash Balance", - value: `$${newCash.toLocaleString()}`, - inline: true, - }); - - if (usedClover) { - resultEmbed.setFooter({ - text: `You have ${userData.inventory["lucky_clover"]} Lucky Clovers left. Win chance was ${Math.round(winChance * 100)}%.`, - }); - } else if (usedCharm) { - resultEmbed.setFooter({ - text: `You have ${userData.inventory["lucky_charm"]} Lucky Charm uses left. Win chance was ${Math.round(winChance * 100)}%.`, - }); - } else { - resultEmbed.setFooter({ - text: `Next gamble available in 5 minutes. Base win chance: ${Math.round(BASE_WIN_CHANCE * 100)}%.`, - }); - } - - await InteractionHelper.safeEditReply(interaction, { embeds: [resultEmbed] }); - }, { command: 'gamble' }) -}; \ No newline at end of file diff --git a/src/commands/Economy/inventory.js b/src/commands/Economy/inventory.js deleted file mode 100644 index b7efcfec80..0000000000 --- a/src/commands/Economy/inventory.js +++ /dev/null @@ -1,70 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { shopItems } from '../../config/shop/items.js'; -import { getEconomyData } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -const SHOP_ITEMS = shopItems; - -export default { - data: new SlashCommandBuilder() - .setName('inventory') - .setDescription('View your economy inventory'), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userId = interaction.user.id; - const guildId = interaction.guildId; - - logger.debug(`[ECONOMY] Inventory requested for ${userId}`, { userId, guildId }); - - const userData = await getEconomyData(client, guildId, userId); - - if (!userData) { - throw createError( - "Failed to load economy data for inventory", - ErrorTypes.DATABASE, - "Failed to load your economy data. Please try again later.", - { userId, guildId } - ); - } - - const inventory = userData.inventory || {}; - - let inventoryDescription = "Your inventory is currently empty."; - - if (Object.keys(inventory).length > 0) { - inventoryDescription = Object.entries(inventory) - .filter( - ([itemId, quantity]) => { - const item = SHOP_ITEMS.find(i => i.id === itemId); - return quantity > 0 && item; - } - ) - .map( - ([itemId, quantity]) => { - const item = SHOP_ITEMS.find(i => i.id === itemId); - return `**${item.name}:** ${quantity}x`; - } - ) - .join("\n"); - } - - logger.info(`[ECONOMY] Inventory retrieved`, { - userId, - guildId, - itemCount: Object.keys(inventory).length - }); - - const embed = createEmbed({ - title: `🎒 ${interaction.user.username}'s Inventory`, - description: inventoryDescription, - }).setThumbnail(interaction.user.displayAvatarURL()); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - }, { command: 'inventory' }) -}; \ No newline at end of file diff --git a/src/commands/Economy/mine.js b/src/commands/Economy/mine.js deleted file mode 100644 index fda8ba1dda..0000000000 --- a/src/commands/Economy/mine.js +++ /dev/null @@ -1,93 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -const MINE_COOLDOWN = 60 * 60 * 1000; -const BASE_MIN_REWARD = 400; -const BASE_MAX_REWARD = 1200; -const PICKAXE_MULTIPLIER = 1.2; -const DIAMOND_PICKAXE_MULTIPLIER = 2.0; - -const MINE_LOCATIONS = [ - "abandoned gold mine", - "dark, damp cave", - "backyard rock quarry", - "volcanic obsidian vent", - "deep-sea mineral trench", -]; - -export default { - data: new SlashCommandBuilder() - .setName('mine') - .setDescription('Go mining to earn money'), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userId = interaction.user.id; - const guildId = interaction.guildId; - const now = Date.now(); - - const userData = await getEconomyData(client, guildId, userId); - const lastMine = userData.lastMine || 0; - const hasDiamondPickaxe = userData.inventory["diamond_pickaxe"] || 0; - const hasPickaxe = userData.inventory["pickaxe"] || 0; - - if (now < lastMine + MINE_COOLDOWN) { - const remaining = lastMine + MINE_COOLDOWN - now; - const hours = Math.floor(remaining / (1000 * 60 * 60)); - const minutes = Math.floor( - (remaining % (1000 * 60 * 60)) / (1000 * 60), - ); - - throw createError( - "Mining cooldown active", - ErrorTypes.RATE_LIMIT, - `Your pickaxe is cooling down. Wait for **${hours}h ${minutes}m** before mining again.`, - { remaining, cooldownType: 'mine' } - ); - } - - const baseEarned = - Math.floor( - Math.random() * (BASE_MAX_REWARD - BASE_MIN_REWARD + 1), - ) + BASE_MIN_REWARD; - - let finalEarned = baseEarned; - let multiplierMessage = ""; - - if (hasDiamondPickaxe > 0) { - finalEarned = Math.floor(baseEarned * DIAMOND_PICKAXE_MULTIPLIER); - multiplierMessage = `\n💎 **Diamond Pickaxe Bonus: +100%**`; - } else if (hasPickaxe > 0) { - finalEarned = Math.floor(baseEarned * PICKAXE_MULTIPLIER); - multiplierMessage = `\n⛏️ **Pickaxe Bonus: +20%**`; - } - - const location = - MINE_LOCATIONS[ - Math.floor(Math.random() * MINE_LOCATIONS.length) - ]; - - userData.wallet += finalEarned; -userData.lastMine = now; - - await setEconomyData(client, guildId, userId, userData); - - const embed = successEmbed( - "💰 Mining Expedition Successful!", - `You explored a **${location}** and managed to find minerals worth **$${finalEarned.toLocaleString()}**!${multiplierMessage}`, - ) - .addFields({ - name: "New Cash Balance", - value: `$${userData.wallet.toLocaleString()}`, - inline: true, - }) - .setFooter({ text: `Next mine available in 1 hour.` }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - }, { command: 'mine' }) -}; \ No newline at end of file diff --git a/src/commands/Economy/modules/economy_dashboard.js b/src/commands/Economy/modules/economy_dashboard.js deleted file mode 100644 index 04f507ff97..0000000000 --- a/src/commands/Economy/modules/economy_dashboard.js +++ /dev/null @@ -1,527 +0,0 @@ -import { - ActionRowBuilder, - StringSelectMenuBuilder, - StringSelectMenuOptionBuilder, - ModalBuilder, - TextInputBuilder, - TextInputStyle, - UserSelectMenuBuilder, - LabelBuilder, - ButtonBuilder, - ButtonStyle, - MessageFlags, - ComponentType, - EmbedBuilder, -} from 'discord.js'; -import { getColor, BotConfig } from '../../../config/bot.js'; -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -import { successEmbed } from '../../../utils/embeds.js'; -import { logger } from '../../../utils/logger.js'; -import { TitanBotError, ErrorTypes, replyUserError } from '../../../utils/errorHandler.js'; -import { getEconomyPrefix } from '../../../utils/database.js'; -import { getEconomyData, addMoney, removeMoney, getMaxBankCapacity } from '../../../utils/economy.js'; -import fs from 'fs/promises'; -import path from 'path'; -import { fileURLToPath } from 'url'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -async function buildDashboardEmbed(guild, client) { - const currencySymbol = BotConfig.economy.currency.symbol; - const currencyName = BotConfig.economy.currency.name; - - let totalInCirculation = 0; - let userCount = 0; - - try { - const economyKeys = await client.db.list(getEconomyPrefix(guild.id)); - - if (economyKeys && economyKeys.length > 0) { - for (const key of economyKeys) { - const userId = key.split(':').pop(); - - const member = await guild.members.fetch(userId).catch(() => null); - if (member?.user?.bot) continue; - - const userData = await client.db.get(key, {}); - if (userData) { - totalInCirculation += (userData.wallet || 0) + (userData.bank || 0); - userCount++; - } - } - } - } catch (error) { - logger.error('Error calculating economy stats:', error); - } - - const avgBalance = userCount > 0 ? Math.floor(totalInCirculation / userCount) : 0; - - return new EmbedBuilder() - .setTitle('💰 Economy Dashboard') - .setDescription(`Manage the economy system for **${guild.name}**.\nSelect an option below to perform an action.`) - .setColor(getColor('economy')) - .addFields( - { name: '💰 Total in Circulation', value: `\`${currencySymbol}${totalInCirculation.toLocaleString()}\``, inline: true }, - { name: '👥 Active Users', value: `\`${userCount.toLocaleString()}\``, inline: true }, - { name: '📊 Average Balance', value: `\`${currencySymbol}${avgBalance.toLocaleString()}\``, inline: true }, - { name: '💱 Currency Symbol', value: `\`${currencySymbol}\``, inline: true }, - { name: '📝 Currency Name', value: `\`${currencyName}\``, inline: true }, - ) - .setFooter({ text: 'Dashboard closes after 10 minutes of inactivity' }) - .setTimestamp(); -} - -function buildSelectMenu(guildId) { - return new StringSelectMenuBuilder() - .setCustomId(`economy_dashboard_${guildId}`) - .setPlaceholder('Select an action...') - .addOptions( - new StringSelectMenuOptionBuilder() - .setLabel('Add Currency') - .setDescription('Add currency to a user\'s wallet or bank') - .setValue('add_currency') - .setEmoji('💰'), - new StringSelectMenuOptionBuilder() - .setLabel('Remove Currency') - .setDescription('Remove currency from a user\'s wallet or bank') - .setValue('remove_currency') - .setEmoji('💸'), - new StringSelectMenuOptionBuilder() - .setLabel('Change Currency Symbol') - .setDescription('Change the currency symbol (e.g., $, €, £)') - .setValue('change_currency') - .setEmoji('💱'), - new StringSelectMenuOptionBuilder() - .setLabel('Change Currency Name') - .setDescription('Change the currency name (e.g., coins, credits)') - .setValue('change_name') - .setEmoji('📝'), - ); -} - -async function refreshDashboard(rootInteraction, guild, client) { - const selectMenu = buildSelectMenu(guild.id); - await InteractionHelper.safeEditReply(rootInteraction, { - embeds: [await buildDashboardEmbed(guild, client)], - components: [ - new ActionRowBuilder().addComponents(selectMenu), - ], - }).catch(() => {}); -} - -async function updateConfigFile(currencySymbol, currencyName) { - try { - const configPath = path.join(__dirname, '../../../config/bot.js'); - let configContent = await fs.readFile(configPath, 'utf-8'); - - configContent = configContent.replace( - /symbol:\s*"[^"]*"/, - `symbol: "${currencySymbol}"` - ); - - configContent = configContent.replace( - /name:\s*"[^"]*",\s*\/\/\s*Currency display name/, - `name: "${currencyName}", // Currency display name` - ); - - configContent = configContent.replace( - /namePlural:\s*"[^"]*",\s*\/\/\s*Plural display name/, - `namePlural: "${currencyName}s", // Plural display name` - ); - - await fs.writeFile(configPath, configContent, 'utf-8'); - logger.info('Config file updated successfully'); - return true; - } catch (error) { - logger.error('Error updating config file:', error); - return false; - } -} - -export default { - prefixOnly: false, - async execute(interaction, config, client) { - try { - const guild = interaction.guild; - const selectMenu = buildSelectMenu(guild.id); - const selectRow = new ActionRowBuilder().addComponents(selectMenu); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [await buildDashboardEmbed(guild, client)], - components: [selectRow], - }); - - const collector = interaction.channel.createMessageComponentCollector({ - componentType: ComponentType.StringSelect, - filter: i => - i.user.id === interaction.user.id && i.customId === `economy_dashboard_${guild.id}`, - time: 600_000, - }); - - collector.on('collect', async selectInteraction => { - const selectedOption = selectInteraction.values[0]; - try { - switch (selectedOption) { - case 'add_currency': - await handleAddCurrency(selectInteraction, interaction, guild, client); - break; - case 'remove_currency': - await handleRemoveCurrency(selectInteraction, interaction, guild, client); - break; - case 'change_currency': - await handleChangeCurrency(selectInteraction, interaction, guild); - break; - case 'change_name': - await handleChangeName(selectInteraction, interaction, guild); - break; - } - } catch (error) { - if (error instanceof TitanBotError) { - logger.debug(`Economy dashboard validation error: ${error.message}`); - } else { - logger.error('Unexpected economy dashboard error:', error); - } - - const errorMessage = - error instanceof TitanBotError - ? error.userMessage || 'An error occurred while processing your selection.' - : 'An unexpected error occurred while processing your request.'; - - if (!selectInteraction.replied && !selectInteraction.deferred) { - await selectInteraction.deferUpdate().catch(() => {}); - } - - await replyUserError(selectInteraction, { - type: ErrorTypes.UNKNOWN, - message: errorMessage, - }).catch(() => {}); - } - }); - - collector.on('end', async (collected, reason) => { - if (reason === 'time') { - const timeoutEmbed = new EmbedBuilder() - .setTitle('Dashboard Timed Out') - .setDescription('This dashboard has been closed due to inactivity. Please run the command again to continue.') - .setColor(getColor('error')); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [timeoutEmbed], - components: [], - }).catch(() => {}); - } - }); - } catch (error) { - if (error instanceof TitanBotError) throw error; - logger.error('Unexpected error in economy_dashboard:', error); - throw new TitanBotError( - `Economy dashboard failed: ${error.message}`, - ErrorTypes.UNKNOWN, - 'Failed to open the economy dashboard.', - ); - } - }, -}; - -async function handleAddCurrency(selectInteraction, rootInteraction, guild, client) { - const modal = new ModalBuilder() - .setCustomId(`economy_add_currency_${guild.id}`) - .setTitle('Add Currency'); - - const userSelect = new UserSelectMenuBuilder() - .setCustomId('target_user') - .setPlaceholder('Select a user...') - .setMinValues(1) - .setMaxValues(1) - .setRequired(true); - - const userLabel = new LabelBuilder() - .setLabel('Target User') - .setDescription('User to add currency to') - .setUserSelectMenuComponent(userSelect); - - const amountInput = new TextInputBuilder() - .setCustomId('amount') - .setLabel('Amount to add') - .setStyle(TextInputStyle.Short) - .setPlaceholder('100') - .setMinLength(1) - .setMaxLength(10) - .setRequired(true); - - const typeInput = new TextInputBuilder() - .setCustomId('type') - .setLabel('Type (wallet or bank)') - .setStyle(TextInputStyle.Short) - .setPlaceholder('wallet') - .setMinLength(1) - .setMaxLength(5) - .setRequired(true); - - modal.addLabelComponents(userLabel); - modal.addComponents( - new ActionRowBuilder().addComponents(amountInput), - new ActionRowBuilder().addComponents(typeInput), - ); - - await selectInteraction.showModal(modal); - - const submitted = await selectInteraction - .awaitModalSubmit({ - filter: i => i.customId === `economy_add_currency_${guild.id}` && i.user.id === selectInteraction.user.id, - time: 120_000, - }) - .catch(() => null); - - if (!submitted) return; - - const userId = submitted.fields.getField('target_user').values[0]; - const amount = parseInt(submitted.fields.getTextInputValue('amount').trim(), 10); - const type = submitted.fields.getTextInputValue('type').trim().toLowerCase(); - - if (isNaN(amount) || amount <= 0) { - await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Amount must be a positive number.' }); - return; - } - - if (type !== 'wallet' && type !== 'bank') { - await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Type must be either "wallet" or "bank".' }); - return; - } - - const member = await guild.members.fetch(userId).catch(() => null); - if (!member) { - await replyUserError(submitted, { type: ErrorTypes.USER_INPUT, message: 'The specified user is not in this server.' }); - return; - } - - if (member.user.bot) { - await replyUserError(submitted, { type: ErrorTypes.UNKNOWN, message: 'Bots do not have economy accounts.' }); - return; - } - - const { newBalance } = await addMoney(client, guild.id, userId, amount, type); - - const currencySymbol = BotConfig.economy.currency.symbol; - - await submitted.reply({ - embeds: [successEmbed('Currency Added', `Successfully added ${currencySymbol}${amount.toLocaleString()} to ${member.user.tag}'s ${type}.\n**New Balance:** ${currencySymbol}${newBalance.toLocaleString()}`)], - flags: MessageFlags.Ephemeral, - }); - - logger.info(`[ECONOMY_DASHBOARD] Currency added`, { - adminId: submitted.user.id, - targetUserId: userId, - amount, - type, - newBalance, - }); - - await refreshDashboard(rootInteraction, guild, client); -} - -async function handleRemoveCurrency(selectInteraction, rootInteraction, guild, client) { - const modal = new ModalBuilder() - .setCustomId(`economy_remove_currency_${guild.id}`) - .setTitle('Remove Currency'); - - const userSelect = new UserSelectMenuBuilder() - .setCustomId('target_user') - .setPlaceholder('Select a user...') - .setMinValues(1) - .setMaxValues(1) - .setRequired(true); - - const userLabel = new LabelBuilder() - .setLabel('Target User') - .setDescription('User to remove currency from') - .setUserSelectMenuComponent(userSelect); - - const amountInput = new TextInputBuilder() - .setCustomId('amount') - .setLabel('Amount to remove') - .setStyle(TextInputStyle.Short) - .setPlaceholder('100') - .setMinLength(1) - .setMaxLength(10) - .setRequired(true); - - const typeInput = new TextInputBuilder() - .setCustomId('type') - .setLabel('Type (wallet or bank)') - .setStyle(TextInputStyle.Short) - .setPlaceholder('wallet') - .setMinLength(1) - .setMaxLength(5) - .setRequired(true); - - modal.addLabelComponents(userLabel); - modal.addComponents( - new ActionRowBuilder().addComponents(amountInput), - new ActionRowBuilder().addComponents(typeInput), - ); - - await selectInteraction.showModal(modal); - - const submitted = await selectInteraction - .awaitModalSubmit({ - filter: i => i.customId === `economy_remove_currency_${guild.id}` && i.user.id === selectInteraction.user.id, - time: 120_000, - }) - .catch(() => null); - - if (!submitted) return; - - const userId = submitted.fields.getField('target_user').values[0]; - const amount = parseInt(submitted.fields.getTextInputValue('amount').trim(), 10); - const type = submitted.fields.getTextInputValue('type').trim().toLowerCase(); - - if (isNaN(amount) || amount <= 0) { - await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Amount must be a positive number.' }); - return; - } - - if (type !== 'wallet' && type !== 'bank') { - await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Type must be either "wallet" or "bank".' }); - return; - } - - const member = await guild.members.fetch(userId).catch(() => null); - if (!member) { - await replyUserError(submitted, { type: ErrorTypes.USER_INPUT, message: 'The specified user is not in this server.' }); - return; - } - - if (member.user.bot) { - await replyUserError(submitted, { type: ErrorTypes.UNKNOWN, message: 'Bots do not have economy accounts.' }); - return; - } - - const { newBalance } = await removeMoney(client, guild.id, userId, amount, type); - - const currencySymbol = BotConfig.economy.currency.symbol; - - await submitted.reply({ - embeds: [successEmbed('Currency Removed', `Successfully removed ${currencySymbol}${amount.toLocaleString()} from ${member.user.tag}'s ${type}.\n**New Balance:** ${currencySymbol}${newBalance.toLocaleString()}`)], - flags: MessageFlags.Ephemeral, - }); - - logger.info(`[ECONOMY_DASHBOARD] Currency removed`, { - adminId: submitted.user.id, - targetUserId: userId, - amount, - type, - newBalance, - }); - - await refreshDashboard(rootInteraction, guild, client); -} - -async function handleChangeCurrency(selectInteraction, rootInteraction, guild) { - const modal = new ModalBuilder() - .setCustomId(`economy_change_currency_${guild.id}`) - .setTitle('Change Currency Symbol'); - - const symbolInput = new TextInputBuilder() - .setCustomId('currency_symbol') - .setLabel('New Currency Symbol') - .setStyle(TextInputStyle.Short) - .setValue(BotConfig.economy.currency.symbol) - .setPlaceholder('$') - .setMinLength(1) - .setMaxLength(3) - .setRequired(true); - - modal.addComponents(new ActionRowBuilder().addComponents(symbolInput)); - - await selectInteraction.showModal(modal); - - const submitted = await selectInteraction - .awaitModalSubmit({ - filter: i => i.customId === `economy_change_currency_${guild.id}` && i.user.id === selectInteraction.user.id, - time: 120_000, - }) - .catch(() => null); - - if (!submitted) return; - - const newSymbol = submitted.fields.getTextInputValue('currency_symbol').trim(); - - if (newSymbol.length === 0 || newSymbol.length > 3) { - await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Currency symbol must be 1-3 characters long.' }); - return; - } - - const success = await updateConfigFile(newSymbol, BotConfig.economy.currency.name); - - if (!success) { - await replyUserError(submitted, { type: ErrorTypes.UNKNOWN, message: 'Could not update the config file. Please check the logs.' }); - return; - } - - await submitted.reply({ - embeds: [successEmbed('Currency Symbol Updated', `Currency symbol changed to **${newSymbol}**.\n\n**Note:** The bot needs to be restarted for changes to take effect.`)], - flags: MessageFlags.Ephemeral, - }); - - logger.info(`[ECONOMY_DASHBOARD] Currency symbol changed`, { - adminId: submitted.user.id, - oldSymbol: BotConfig.economy.currency.symbol, - newSymbol - }); -} - -async function handleChangeName(selectInteraction, rootInteraction, guild) { - const modal = new ModalBuilder() - .setCustomId(`economy_change_name_${guild.id}`) - .setTitle('Change Currency Name'); - - const nameInput = new TextInputBuilder() - .setCustomId('currency_name') - .setLabel('New Currency Name') - .setStyle(TextInputStyle.Short) - .setValue(BotConfig.economy.currency.name) - .setPlaceholder('coins') - .setMinLength(1) - .setMaxLength(20) - .setRequired(true); - - modal.addComponents(new ActionRowBuilder().addComponents(nameInput)); - - await selectInteraction.showModal(modal); - - const submitted = await selectInteraction - .awaitModalSubmit({ - filter: i => i.customId === `economy_change_name_${guild.id}` && i.user.id === selectInteraction.user.id, - time: 120_000, - }) - .catch(() => null); - - if (!submitted) return; - - const newName = submitted.fields.getTextInputValue('currency_name').trim(); - - if (newName.length === 0 || newName.length > 20) { - await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Currency name must be 1-20 characters long.' }); - return; - } - - const success = await updateConfigFile(BotConfig.economy.currency.symbol, newName); - - if (!success) { - await replyUserError(submitted, { type: ErrorTypes.UNKNOWN, message: 'Could not update the config file. Please check the logs.' }); - return; - } - - await submitted.reply({ - embeds: [successEmbed('Currency Name Updated', `Currency name changed to **${newName}**.\n\n**Note:** The bot needs to be restarted for changes to take effect.`)], - flags: MessageFlags.Ephemeral, - }); - - logger.info(`[ECONOMY_DASHBOARD] Currency name changed`, { - adminId: submitted.user.id, - oldName: BotConfig.economy.currency.name, - newName - }); -} \ No newline at end of file diff --git a/src/commands/Economy/modules/shop_browse.js b/src/commands/Economy/modules/shop_browse.js deleted file mode 100644 index b7adcf40dd..0000000000 --- a/src/commands/Economy/modules/shop_browse.js +++ /dev/null @@ -1,94 +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'; -import { handleInteractionError } from '../../../utils/errorHandler.js'; - -export default { - async execute(interaction, config, client) { - try { - const TARGET_MAX_PAGES = 3; - const ITEMS_PER_PAGE = Math.max(1, Math.ceil(shopItems.length / TARGET_MAX_PAGES)); - const totalPages = Math.ceil(shopItems.length / ITEMS_PER_PAGE); - let currentPage = 1; - - const createShopEmbed = (page) => { - const startIndex = (page - 1) * ITEMS_PER_PAGE; - const pageItems = shopItems.slice(startIndex, startIndex + ITEMS_PER_PAGE); - const embed = new EmbedBuilder() - .setTitle('Store') - .setColor(getColor('primary')) - .setDescription('Use `/buy item_id: quantity:` to purchase an item.'); - pageItems.forEach(item => { - embed.addFields({ - name: `${item.name} (${item.id})`, - value: `**Type:** ${item.type}\n **Price:** $${item.price.toLocaleString()}\n${item.description}`, - inline: false, - }); - }); - embed.setFooter({ text: `Page ${page}/${totalPages}` }); - return embed; - }; - - const createShopComponents = (page) => { - if (totalPages <= 1) return []; - return [ - new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId('shop_prev') - .setLabel('⬅️ Previous') - .setStyle(ButtonStyle.Secondary) - .setDisabled(page === 1), - new ButtonBuilder() - .setCustomId('shop_next') - .setLabel('Next ➡️') - .setStyle(ButtonStyle.Secondary) - .setDisabled(page === totalPages), - ), - ]; - }; - - const message = await interaction.reply({ - embeds: [createShopEmbed(currentPage)], - components: createShopComponents(currentPage), - flags: 0, - }); - - const collector = message.createMessageComponentCollector({ - componentType: ComponentType.Button, - time: 300000, - }); - - collector.on('collect', async (buttonInteraction) => { - if (buttonInteraction.user.id !== interaction.user.id) { - await buttonInteraction.reply({ content: '❌ You cannot use these buttons. Run `/shop` to get your own shop view.', flags: 64 }); - return; - } - const { customId } = buttonInteraction; - if (customId === 'shop_prev' || customId === 'shop_next') { - await buttonInteraction.deferUpdate(); - if (customId === 'shop_prev' && currentPage > 1) currentPage--; - else if (customId === 'shop_next' && currentPage < totalPages) currentPage++; - await buttonInteraction.editReply({ - embeds: [createShopEmbed(currentPage)], - components: createShopComponents(currentPage), - }); - } - }); - - collector.on('end', async () => { - try { - const disabledComponents = createShopComponents(currentPage); - disabledComponents.forEach(row => row.components.forEach(btn => btn.setDisabled(true))); - await message.edit({ components: disabledComponents }); - } catch (error) { - logger.debug('shop_browse: could not disable components on collector end', { - error: error.message, - }); - } - }); - } catch (error) { - await handleInteractionError(interaction, error, { command: 'shop_browse' }); - } - }, -}; \ No newline at end of file diff --git a/src/commands/Economy/modules/shop_config_setrole.js b/src/commands/Economy/modules/shop_config_setrole.js deleted file mode 100644 index e917e9e69a..0000000000 --- a/src/commands/Economy/modules/shop_config_setrole.js +++ /dev/null @@ -1,31 +0,0 @@ -import { PermissionsBitField } from 'discord.js'; -import { successEmbed } from '../../../utils/embeds.js'; -import { getGuildConfig, setGuildConfig } from '../../../services/config/guildConfig.js'; -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -import { logger } from '../../../utils/logger.js'; - -import { replyUserError, ErrorTypes } from '../../../utils/errorHandler.js'; -export default { - async execute(interaction, config, client) { - if (!interaction.member.permissions.has(PermissionsBitField.Flags.ManageGuild)) { - return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need **Manage Server** permissions to set the premium role.' }); - } - - const role = interaction.options.getRole('role'); - const guildId = interaction.guildId; - - try { - const currentConfig = await getGuildConfig(client, guildId); - currentConfig.premiumRoleId = role.id; - await setGuildConfig(client, guildId, currentConfig); - - return InteractionHelper.safeReply(interaction, { - embeds: [successEmbed('Premium Role Set', `The **Premium Shop Role** has been set to ${role.toString()}. Members who purchase the Premium Role item will be granted this role.`)], - ephemeral: true, - }); - } catch (error) { - logger.error('shop_config_setrole error:', error); - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Could not save the guild configuration.' }); - } - }, -}; \ No newline at end of file diff --git a/src/commands/Economy/pay.js b/src/commands/Economy/pay.js deleted file mode 100644 index 5e8e904862..0000000000 --- a/src/commands/Economy/pay.js +++ /dev/null @@ -1,149 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, addMoney, removeMoney, setEconomyData } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import EconomyService from '../../services/economyService.js'; - -export default { - data: new SlashCommandBuilder() - .setName('pay') - .setDescription('Pay another user some of your cash') - .addUserOption(option => - option - .setName('user') - .setDescription('User to pay') - .setRequired(true) - ) - .addIntegerOption(option => - option - .setName('amount') - .setDescription('Amount to pay') - .setRequired(true) - .setMinValue(1) - ), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const senderId = interaction.user.id; - const receiver = interaction.options.getUser("user"); - const amount = interaction.options.getInteger("amount"); - const guildId = interaction.guildId; - - logger.debug(`[ECONOMY] Pay command initiated`, { - senderId, - receiverId: receiver.id, - amount, - guildId - }); - - if (receiver.bot) { - throw createError( - "Cannot pay bot", - ErrorTypes.VALIDATION, - "You cannot pay a bot.", - { receiverId: receiver.id, isBot: true } - ); - } - - if (receiver.id === senderId) { - throw createError( - "Cannot pay self", - ErrorTypes.VALIDATION, - "You cannot pay yourself.", - { senderId, receiverId: receiver.id } - ); - } - - if (amount <= 0) { - throw createError( - "Invalid payment amount", - ErrorTypes.VALIDATION, - "Amount must be greater than zero.", - { amount, senderId } - ); - } - - const [senderData, receiverData] = await Promise.all([ - getEconomyData(client, guildId, senderId), - getEconomyData(client, guildId, receiver.id) - ]); - - if (!senderData) { - throw createError( - "Failed to load sender economy data", - ErrorTypes.DATABASE, - "Failed to load your economy data. Please try again later.", - { userId: senderId, guildId } - ); - } - - if (!receiverData) { - throw createError( - "Failed to load receiver economy data", - ErrorTypes.DATABASE, - "Failed to load the receiver's economy data. Please try again later.", - { userId: receiver.id, guildId } - ); - } - - const result = await EconomyService.transferMoney( - client, - guildId, - senderId, - receiver.id, - amount - ); - - const updatedSenderData = await getEconomyData(client, guildId, senderId); - const updatedReceiverData = await getEconomyData(client, guildId, receiver.id); - - const embed = successEmbed( - 'Payment Successful', - `You successfully paid **${receiver.username}** the amount of **$${amount.toLocaleString()}**!` - ) - .addFields( - { - name: "Payment Amount", - value: `$${amount.toLocaleString()}`, - inline: true, - }, - { - name: "Your New Balance", - value: `$${updatedSenderData.wallet.toLocaleString()}`, - inline: true, - }, - ) - .setFooter({ - text: `Paid to ${receiver.tag}`, - iconURL: receiver.displayAvatarURL(), - }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - - logger.info(`[ECONOMY] Payment sent successfully`, { - senderId, - receiverId: receiver.id, - amount, - senderBalance: updatedSenderData.wallet, - receiverBalance: updatedReceiverData.wallet - }); - - try { - const receiverEmbed = createEmbed({ - title: "Incoming Payment!", - description: `${interaction.user.username} paid you **$${amount.toLocaleString()}**.` - }).addFields({ - name: "Your New Cash", - value: `$${updatedReceiverData.wallet.toLocaleString()}`, - inline: true, - }); - await receiver.send({ embeds: [receiverEmbed] }); - } catch (e) { - logger.warn(`Could not DM user ${receiver.id}: ${e.message}`); - } - }, { command: 'pay' }) -}; \ No newline at end of file diff --git a/src/commands/Economy/rob.js b/src/commands/Economy/rob.js deleted file mode 100644 index 68f74fbfee..0000000000 --- a/src/commands/Economy/rob.js +++ /dev/null @@ -1,154 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { successEmbed, warningEmbed, buildUserErrorEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { BotConfig } from '../../config/bot.js'; - -const ROB_COOLDOWN = BotConfig.economy?.cooldowns?.rob ?? 4 * 60 * 60 * 1000; -const BASE_ROB_SUCCESS_CHANCE = BotConfig.economy?.robSuccessRate ?? 0.4; -const ROB_PERCENTAGE = 0.15; -const FINE_PERCENTAGE = 0.1; - -export default { - data: new SlashCommandBuilder() - .setName('rob') - .setDescription('Attempt to rob another user (very risky)') - .addUserOption(option => - option - .setName('user') - .setDescription('User to rob') - .setRequired(true) - ), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const robberId = interaction.user.id; - const victimUser = interaction.options.getUser("user"); - const guildId = interaction.guildId; - const now = Date.now(); - - if (robberId === victimUser.id) { - throw createError( - "Cannot rob self", - ErrorTypes.VALIDATION, - "You cannot rob yourself.", - { robberId, victimId: victimUser.id } - ); - } - - if (victimUser.bot) { - throw createError( - "Cannot rob bot", - ErrorTypes.VALIDATION, - "You cannot rob a bot.", - { victimId: victimUser.id, isBot: true } - ); - } - - const robberData = await getEconomyData(client, guildId, robberId); - const victimData = await getEconomyData(client, guildId, victimUser.id); - - if (!robberData || !victimData) { - throw createError( - "Failed to load economy data", - ErrorTypes.DATABASE, - "Failed to load economy data. Please try again later.", - { robberId: !!robberData, victimId: !!victimData, guildId } - ); - } - - const lastRob = robberData.lastRob || 0; - - if (now < lastRob + ROB_COOLDOWN) { - const remaining = lastRob + ROB_COOLDOWN - now; - const hours = Math.floor(remaining / (1000 * 60 * 60)); - const minutes = Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60)); - - throw createError( - "Robbery cooldown active", - ErrorTypes.RATE_LIMIT, - `You need to lay low. Wait **${hours}h ${minutes}m** before attempting another robbery.`, - { remaining, hours, minutes, cooldownType: 'rob' } - ); - } - - if (victimData.wallet < 500) { - throw createError( - "Victim too poor", - ErrorTypes.VALIDATION, - `${victimUser.username} is too poor. They need at least $500 cash to be worth robbing.`, - { victimWallet: victimData.wallet, required: 500 } - ); - } - - const hasSafe = victimData.inventory["personal_safe"] || 0; - - if (hasSafe > 0) { - robberData.lastRob = now; - await setEconomyData(client, guildId, robberId, robberData); - - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - warningEmbed( - 'Robbery Blocked', - `${victimUser.username} was prepared! Your attempt failed because they own a **Personal Safe**. You got away clean but didn't gain anything.` - ) - ], - }); - } - - const isSuccessful = Math.random() < BASE_ROB_SUCCESS_CHANCE; - let resultEmbed; - - if (isSuccessful) { - const amountStolen = Math.floor(victimData.wallet * ROB_PERCENTAGE); - - robberData.wallet = (robberData.wallet || 0) + amountStolen; - victimData.wallet = (victimData.wallet || 0) - amountStolen; - - resultEmbed = successEmbed( - 'Robbery Successful', - `You successfully stole **$${amountStolen.toLocaleString()}** from ${victimUser.username}!` - ); - } else { - const fineAmount = Math.floor((robberData.wallet || 0) * FINE_PERCENTAGE); - - if ((robberData.wallet || 0) < fineAmount) { - robberData.wallet = 0; - } else { - robberData.wallet = (robberData.wallet || 0) - fineAmount; - } - - resultEmbed = buildUserErrorEmbed( - 'unknown', - `You failed the robbery and were caught! You were fined **$${fineAmount.toLocaleString()}** of your own cash.`, - { titleOverride: 'Robbery Failed' } - ); - } - - robberData.lastRob = now; - - await setEconomyData(client, guildId, robberId, robberData); - await setEconomyData(client, guildId, victimUser.id, victimData); - - resultEmbed - .addFields( - { - name: `Your New Cash (${interaction.user.username})`, - value: `$${robberData.wallet.toLocaleString()}`, - inline: true, - }, - { - name: `Victim's New Cash (${victimUser.username})`, - value: `$${victimData.wallet.toLocaleString()}`, - inline: true, - }, - ) - .setFooter({ text: `Next robbery available in ${Math.ceil(ROB_COOLDOWN / (60 * 60 * 1000))} hours.` }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [resultEmbed] }); - }, { command: 'rob' }) -}; \ No newline at end of file diff --git a/src/commands/Economy/shop-config.js b/src/commands/Economy/shop-config.js deleted file mode 100644 index fb3be41dc3..0000000000 --- a/src/commands/Economy/shop-config.js +++ /dev/null @@ -1,28 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import shopConfigSetrole from './modules/shop_config_setrole.js'; - -export default { - slashOnly: true, - data: new SlashCommandBuilder() - .setName('shop-config') - .setDescription('Configure shop settings. (Manage Server required)') - .addSubcommand(subcommand => - subcommand - .setName('setrole') - .setDescription('Set the Discord role granted when the Premium Role shop item is purchased.') - .addRoleOption(option => - option - .setName('role') - .setDescription('The role to grant for Premium Role purchases.') - .setRequired(true), - ), - ), - - async execute(interaction, config, client) { - const subcommand = interaction.options.getSubcommand(); - - if (subcommand === 'setrole') { - return shopConfigSetrole.execute(interaction, config, client); - } - }, -}; diff --git a/src/commands/Economy/shop.js b/src/commands/Economy/shop.js deleted file mode 100644 index 16b2b62d96..0000000000 --- a/src/commands/Economy/shop.js +++ /dev/null @@ -1,13 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import shopBrowse from './modules/shop_browse.js'; - -export default { - slashOnly: true, - data: new SlashCommandBuilder() - .setName('shop') - .setDescription('Browse the economy shop.'), - - async execute(interaction, config, client) { - return shopBrowse.execute(interaction, config, client); - }, -}; diff --git a/src/commands/Economy/slut.js b/src/commands/Economy/slut.js deleted file mode 100644 index 98b83e369e..0000000000 --- a/src/commands/Economy/slut.js +++ /dev/null @@ -1,188 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -const SLUT_COOLDOWN = 45 * 60 * 1000; - -const SLUT_ACTIVITIES = [ - { name: "Cam Stream", min: 120, max: 450, risk: 0.2 }, - { name: "Private Dance Session", min: 220, max: 700, risk: 0.25 }, - { name: "After-Hours Club Host", min: 320, max: 900, risk: 0.3 }, - { name: "VIP Companion Booking", min: 550, max: 1400, risk: 0.35 }, - { name: "Exclusive Livestream", min: 850, max: 2200, risk: 0.4 }, -]; - -const POSITIVE_OUTCOMES = [ - "Your stream blew up and tips poured in.", - "A VIP booking paid far above average.", - "Your after-hours shift was packed and profitable.", - "Premium requests came through and your payout jumped.", -]; - -const FINE_OUTCOMES = [ - "Venue security issued a compliance fine.", - "A moderation strike triggered a platform fee.", - "You were flagged and had to pay a penalty.", -]; - -const ROBBED_OUTCOMES = [ - "A fake buyer chargeback wiped part of your earnings.", - "A scam booking cleaned out a chunk of your cash.", - "You got baited by a fraud account and lost money.", -]; - -const LOSS_OUTCOMES = [ - "The set flopped and you had to cover operating costs.", - "You burned budget on prep and made no return.", - "The shift went sideways and left you in the red.", -]; - -function randomInt(min, max) { - return Math.floor(Math.random() * (max - min + 1)) + min; -} - -function randomChoice(items) { - return items[Math.floor(Math.random() * items.length)]; -} - -function resolveOutcome(activity, wallet) { - const successChance = Math.max(0.35, 0.55 - activity.risk * 0.2); - const fineChance = 0.22; - const robbedChance = 0.2; - const roll = Math.random(); - - if (roll < successChance) { - const amount = randomInt(activity.min, activity.max); - return { - type: 'payout', - delta: amount, - message: randomChoice(POSITIVE_OUTCOMES), - title: `${activity.name} - Payout` - }; - } - - const remainingAfterSuccess = roll - successChance; - - if (remainingAfterSuccess < fineChance) { - const maxFine = Math.min(wallet, Math.max(150, Math.floor(activity.max * 0.4))); - const minFine = Math.min(maxFine, Math.max(50, Math.floor(activity.min * 0.2))); - const amount = maxFine > 0 ? randomInt(minFine, maxFine) : 0; - return { - type: 'fine', - delta: -amount, - message: randomChoice(FINE_OUTCOMES), - title: `${activity.name} - Fined` - }; - } - - if (remainingAfterSuccess < fineChance + robbedChance) { - const maxRobbed = Math.min(wallet, Math.max(200, Math.floor(wallet * 0.35))); - const minRobbed = Math.min(maxRobbed, Math.max(75, Math.floor(wallet * 0.1))); - const amount = maxRobbed > 0 ? randomInt(minRobbed, maxRobbed) : 0; - return { - type: 'robbed', - delta: -amount, - message: randomChoice(ROBBED_OUTCOMES), - title: `${activity.name} - Robbed` - }; - } - - const maxLoss = Math.min(wallet, Math.max(100, Math.floor(activity.max * 0.3))); - const minLoss = Math.min(maxLoss, Math.max(40, Math.floor(activity.min * 0.15))); - const amount = maxLoss > 0 ? randomInt(minLoss, maxLoss) : 0; - return { - type: 'loss', - delta: -amount, - message: randomChoice(LOSS_OUTCOMES), - title: `${activity.name} - Loss` - }; -} - -export default { - data: new SlashCommandBuilder() - .setName('slut') - .setDescription('Take a risky provocative job for random payout or loss'), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userId = interaction.user.id; - const guildId = interaction.guildId; - const now = Date.now(); - - logger.debug(`[ECONOMY] Slut command started for ${userId}`, { userId, guildId }); - - const userData = await getEconomyData(client, guildId, userId); - - if (!userData) { - throw createError( - "Failed to load economy data for slut command", - ErrorTypes.DATABASE, - "Failed to load your economy data. Please try again later.", - { userId, guildId } - ); - } - - const lastSlut = userData.lastSlut || 0; - - if (now - lastSlut < SLUT_COOLDOWN) { - const remainingTime = lastSlut + SLUT_COOLDOWN - now; - throw createError( - "Slut cooldown active", - ErrorTypes.RATE_LIMIT, - `You need to wait before you can work again! Try again in **${Math.ceil(remainingTime / 60000)}** minutes.`, - { timeRemaining: remainingTime, cooldownType: 'slut' } - ); - } - - const activity = randomChoice(SLUT_ACTIVITIES); - - const outcome = resolveOutcome(activity, userData.wallet || 0); - - userData.lastSlut = now; - userData.totalSluts = (userData.totalSluts || 0) + 1; - userData.totalSlutEarnings = (userData.totalSlutEarnings || 0) + Math.max(0, outcome.delta); - userData.totalSlutLosses = (userData.totalSlutLosses || 0) + Math.max(0, -outcome.delta); - - if (outcome.type !== 'payout') { - userData.failedSluts = (userData.failedSluts || 0) + 1; - } - - userData.wallet = Math.max(0, (userData.wallet || 0) + outcome.delta); - - await setEconomyData(client, guildId, userId, userData); - - logger.info(`[ECONOMY_TRANSACTION] Slut activity resolved`, { - userId, - guildId, - activity: activity.name, - outcomeType: outcome.type, - amountDelta: outcome.delta, - newWallet: userData.wallet, - timestamp: new Date().toISOString() - }); - - const amountLabel = `${outcome.delta >= 0 ? '+' : '-'}$${Math.abs(outcome.delta).toLocaleString()}`; - const summaryLines = [ - `${outcome.message}`, - `💸 **Net Result:** ${amountLabel}`, - `💳 **Current Balance:** $${userData.wallet.toLocaleString()}`, - `📊 **Total Sessions:** ${userData.totalSluts}`, - `💵 **Total Earned:** $${(userData.totalSlutEarnings || 0).toLocaleString()}`, - `🧾 **Total Lost:** $${(userData.totalSlutLosses || 0).toLocaleString()}` - ]; - - const embed = createEmbed({ - title: outcome.title, - description: summaryLines.join('\n'), - color: outcome.delta >= 0 ? 'success' : 'error', - timestamp: true - }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - }, { command: 'slut' }) -}; \ No newline at end of file diff --git a/src/commands/Economy/withdraw.js b/src/commands/Economy/withdraw.js deleted file mode 100644 index bf45af02ad..0000000000 --- a/src/commands/Economy/withdraw.js +++ /dev/null @@ -1,85 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData, getMaxBankCapacity } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName('withdraw') - .setDescription('Withdraw money from your bank to your wallet') - .addIntegerOption(option => - option - .setName('amount') - .setDescription('Amount to withdraw') - .setRequired(true) - .setMinValue(1) - ), - - execute: withErrorHandling(async (interaction, config, client) => { - await InteractionHelper.safeDefer(interaction); - - const userId = interaction.user.id; - const guildId = interaction.guildId; - const amountInput = interaction.options.getInteger("amount"); - - const userData = await getEconomyData(client, guildId, userId); - - if (!userData) { - throw createError( - "Failed to load economy data", - ErrorTypes.DATABASE, - "Failed to load your economy data. Please try again later.", - { userId, guildId } - ); - } - - let withdrawAmount = amountInput; - - if (withdrawAmount <= 0) { - throw createError( - "Invalid withdrawal amount", - ErrorTypes.VALIDATION, - "You must withdraw a positive amount.", - { amount: withdrawAmount, userId } - ); - } - - if (withdrawAmount > userData.bank) { - withdrawAmount = userData.bank; - } - - if (withdrawAmount === 0) { - throw createError( - "Empty bank account", - ErrorTypes.VALIDATION, - "Your bank account is empty.", - { userId, bankBalance: userData.bank } - ); - } - - userData.wallet += withdrawAmount; - userData.bank -= withdrawAmount; - - await setEconomyData(client, guildId, userId, userData); - - const embed = successEmbed( - 'Withdrawal Successful', - `You successfully withdrew **$${withdrawAmount.toLocaleString()}** from your bank.` - ) - .addFields( - { - name: "New Cash Balance", - value: `$${userData.wallet.toLocaleString()}`, - inline: true, - }, - { - name: "New Bank Balance", - value: `$${userData.bank.toLocaleString()}`, - inline: true, - }, - ); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - }, { command: 'withdraw' }) -}; \ No newline at end of file diff --git a/src/commands/Economy/work.js b/src/commands/Economy/work.js deleted file mode 100644 index 775be897ad..0000000000 --- a/src/commands/Economy/work.js +++ /dev/null @@ -1,123 +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'; -import { botConfig } from '../../config/bot.js'; - -const WORK_COOLDOWN = botConfig.economy?.cooldowns?.work ?? 30 * 60 * 1000; -const MIN_WORK_AMOUNT = botConfig.economy?.workMin ?? 10; -const MAX_WORK_AMOUNT = botConfig.economy?.workMax ?? 100; -const LAPTOP_MULTIPLIER = 1.5; -const WORK_JOBS = [ - "Software Developer", - "Barista", - "Janitor", - "YouTuber", - "Discord Bot Developer", - "Cashier", - "Pizza Delivery Driver", - "Librarian", - "Gardener", - "Data Analyst", -]; - -export default { - data: new SlashCommandBuilder() - .setName('work') - .setDescription('Work to earn some money'), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userId = interaction.user.id; - const guildId = interaction.guildId; - const now = Date.now(); - - const userData = await getEconomyData(client, guildId, userId); - - if (!userData) { - throw createError( - "Failed to load economy data for work", - ErrorTypes.DATABASE, - "Failed to load your economy data. Please try again later.", - { userId, guildId } - ); - } - - logger.debug(`[ECONOMY] Work command started for ${userId}`, { userId, guildId }); - - const lastWork = userData.lastWork || 0; - const inventory = userData.inventory || {}; - const extraWorkShifts = inventory["extra_work"] || 0; - const hasLaptop = inventory["laptop"] || 0; - - let cooldownActive = now < lastWork + WORK_COOLDOWN; - let usedConsumable = false; - - if (cooldownActive) { - if (extraWorkShifts > 0) { - inventory["extra_work"] = (inventory["extra_work"] || 0) - 1; - usedConsumable = true; - } else { - const remaining = lastWork + WORK_COOLDOWN - now; - throw createError( - "Work cooldown active", - ErrorTypes.RATE_LIMIT, - `You're working too fast! Wait **${Math.floor(remaining / 3600000)}h ${Math.floor((remaining % 3600000) / 60000)}m** before working again.`, - { timeRemaining: remaining, cooldownType: 'work' } - ); - } - } - - let earned = Math.floor(Math.random() * (MAX_WORK_AMOUNT - MIN_WORK_AMOUNT + 1)) + MIN_WORK_AMOUNT; - const job = WORK_JOBS[Math.floor(Math.random() * WORK_JOBS.length)]; - - let multiplierMessage = ""; - if (hasLaptop > 0) { - earned = Math.floor(earned * LAPTOP_MULTIPLIER); - multiplierMessage = "\n💻 **Laptop Bonus:** +50% earnings!"; - } - - userData.wallet = (userData.wallet || 0) + earned; - userData.lastWork = now; - - await setEconomyData(client, guildId, userId, userData); - - logger.info(`[ECONOMY_TRANSACTION] Work completed`, { - userId, - guildId, - amount: earned, - job, - usedConsumable, - hasLaptop: hasLaptop > 0, - newWallet: userData.wallet, - timestamp: new Date().toISOString() - }); - - const embed = successEmbed( - "💼 Work Complete!", - `You worked as a **${job}** and earned **$${earned.toLocaleString()}**!${multiplierMessage}` - ) - .addFields( - { - name: "New Balance", - value: `$${userData.wallet.toLocaleString()}`, - inline: true, - }, - { - name: "Next Work", - value: ``, - inline: true, - } - ) - .setFooter({ - text: `Requested by ${interaction.user.tag}`, - iconURL: interaction.user.displayAvatarURL(), - }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - }, { command: 'work' }) -}; \ No newline at end of file diff --git a/src/commands/JoinToCreate/tempvoice.js b/src/commands/JoinToCreate/tempvoice.js new file mode 100644 index 0000000000..693bee380b --- /dev/null +++ b/src/commands/JoinToCreate/tempvoice.js @@ -0,0 +1,87 @@ +import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; +import { initializeJoinToCreate, getConfiguration, removeTriggerChannel } from '../../services/joinToCreateService.js'; +import { getTempVoiceConfig, saveTempVoiceConfig } from '../../services/tempVoiceService.js'; + +export default { + data: new SlashCommandBuilder() + .setName('tempvoice') + .setDescription('Set up the temporary voice room system.') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) + .setDMPermission(false) + .addSubcommand(sub => sub.setName('setup').setDescription('Create Join to Create and the control panel.')) + .addSubcommand(sub => sub.setName('reset').setDescription('Remove the TempVoice setup.')), + + async execute(interaction, config, client) { + const guild = interaction.guild; + const subcommand = interaction.options.getSubcommand(); + const current = await getTempVoiceConfig(client, guild.id); + + if (subcommand === 'reset') { + const jtc = await getConfiguration(client, guild.id).catch(() => null); + for (const channelId of Object.keys(jtc?.temporaryChannels || {})) await guild.channels.delete(channelId).catch(() => {}); + if (jtc?.triggerChannels?.length) { + for (const triggerId of jtc.triggerChannels) await removeTriggerChannel(client, guild.id, triggerId).catch(() => {}); + } + if (current.triggerChannelId) await guild.channels.delete(current.triggerChannelId).catch(() => {}); + if (current.panelChannelId) await guild.channels.delete(current.panelChannelId).catch(() => {}); + if (current.categoryId) await guild.channels.delete(current.categoryId).catch(() => {}); + await saveTempVoiceConfig(client, guild.id, { categoryId: null, triggerChannelId: null, panelChannelId: null, panelMessageId: null, rooms: {} }); + return interaction.reply({ content: '✅ TempVoice setup has been reset.', ephemeral: true }); + } + + if (current.triggerChannelId && guild.channels.cache.has(current.triggerChannelId)) { + return interaction.reply({ content: `⚠️ TempVoice is already set up: <#${current.triggerChannelId}>`, ephemeral: true }); + } + + const existingJtc = await getConfiguration(client, guild.id); + if (existingJtc?.triggerChannels?.length) { + return interaction.reply({ content: `⚠️ This server already has a Join to Create system: <#${existingJtc.triggerChannels[0]}>. Reset it first with \`/tempvoice reset\`.`, ephemeral: true }); + } + + await interaction.deferReply({ ephemeral: true }); + + const category = await guild.channels.create({ name: 'Temporary Voice', type: ChannelType.GuildCategory }); + const trigger = await guild.channels.create({ name: '➕・Join to Create', type: ChannelType.GuildVoice, parent: category.id }); + const panel = await guild.channels.create({ name: 'tempvoice-panel', type: ChannelType.GuildText, parent: category.id }); + + await initializeJoinToCreate(client, guild.id, trigger.id, { + nameTemplate: "{username}'s Room", + userLimit: 0, + bitrate: 64000, + categoryId: category.id, + }); + + const embed = new EmbedBuilder() + .setColor(0x5865f2) + .setTitle('🎙️ Temporary Voice Rooms') + .setDescription(`Join <#${trigger.id}> to create your temporary voice room.\n\nUse the panel below while you are inside your room to manage it.`) + .addFields({ name: 'Controls', value: '🔒 Lock • 👁️ Hide • ✏️ Rename • 👥 Limit\n🚫 Kick • 🔇 Mute • 👑 Transfer • 🗑️ Delete' }) + .setFooter({ text: 'Only the room owner can use these controls.' }); + + const rows = [ + new ActionRowBuilder().addComponents( + new ButtonBuilder().setCustomId('tempvoice_lock').setLabel('🔒 Lock').setStyle(ButtonStyle.Secondary), + new ButtonBuilder().setCustomId('tempvoice_hide').setLabel('👁️ Hide').setStyle(ButtonStyle.Secondary), + new ButtonBuilder().setCustomId('tempvoice_rename').setLabel('✏️ Rename').setStyle(ButtonStyle.Primary), + new ButtonBuilder().setCustomId('tempvoice_limit').setLabel('👥 Limit').setStyle(ButtonStyle.Primary), + ), + new ActionRowBuilder().addComponents( + new ButtonBuilder().setCustomId('tempvoice_kick').setLabel('🚫 Kick').setStyle(ButtonStyle.Danger), + new ButtonBuilder().setCustomId('tempvoice_mute').setLabel('🔇 Mute').setStyle(ButtonStyle.Secondary), + new ButtonBuilder().setCustomId('tempvoice_transfer').setLabel('👑 Transfer').setStyle(ButtonStyle.Primary), + new ButtonBuilder().setCustomId('tempvoice_delete').setLabel('🗑️ Delete').setStyle(ButtonStyle.Danger), + ), + ]; + + const message = await panel.send({ embeds: [embed], components: rows }); + await saveTempVoiceConfig(client, guild.id, { + categoryId: category.id, + triggerChannelId: trigger.id, + panelChannelId: panel.id, + panelMessageId: message.id, + rooms: {}, + }); + + await interaction.editReply(`✅ TempVoice is ready.\n\n🎙️ Join to Create: ${trigger}\n🎛️ Control Panel: ${panel}`); + }, +}; diff --git a/src/commands/Moderation/hide.js b/src/commands/Moderation/hide.js new file mode 100644 index 0000000000..f120aa28a5 --- /dev/null +++ b/src/commands/Moderation/hide.js @@ -0,0 +1,77 @@ +import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; +import { successEmbed } from '../../utils/embeds.js'; +import { logEvent } from '../../utils/moderation.js'; +import { logger } from '../../utils/logger.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; + +const TARGET_ROLE_ID = '1534935138440314960'; + +export default { + data: new SlashCommandBuilder() + .setName('hide') + .setDescription('Hides the current channel from the configured role.') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels), + + category: 'moderation', + + async execute(interaction, config, client) { + const deferSuccess = await InteractionHelper.safeDefer(interaction); + if (!deferSuccess) return; + + const channel = interaction.channel; + const role = interaction.guild.roles.cache.get(TARGET_ROLE_ID); + + if (!role) { + return await replyUserError(interaction, { + type: ErrorTypes.UNKNOWN, + message: 'The configured role could not be found in this server.' + }); + } + + try { + await channel.permissionOverwrites.edit( + role, + { + ViewChannel: false + }, + { + type: 0, + reason: `Channel hidden from ${role.name} by ${interaction.user.tag}` + } + ); + + await logEvent({ + client, + guild: interaction.guild, + event: { + action: 'Channel Hidden', + target: channel.toString(), + executor: `${interaction.user.tag} (${interaction.user.id})`, + metadata: { + channelId: channel.id, + roleId: role.id, + roleName: role.name, + moderatorId: interaction.user.id + } + } + }); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [ + successEmbed( + '🔒 Channel Hidden', + `${channel} is now hidden from ${role}.` + ) + ] + }); + } catch (error) { + logger.error('Hide command error:', error); + + await replyUserError(interaction, { + type: ErrorTypes.PERMISSION, + message: 'I could not modify the channel permissions. Make sure I have Manage Channels.' + }); + } + } +}; diff --git a/src/commands/Moderation/lock.js b/src/commands/Moderation/lock.js index 75b251973f..50cfbe0563 100644 --- a/src/commands/Moderation/lock.js +++ b/src/commands/Moderation/lock.js @@ -1,56 +1,64 @@ -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType } from 'discord.js'; -import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; +import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; +import { successEmbed } from '../../utils/embeds.js'; import { logEvent } from '../../utils/moderation.js'; import { logger } from '../../utils/logger.js'; -import { getColor } from '../../config/bot.js'; - import { InteractionHelper } from '../../utils/interactionHelper.js'; import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; + +const TARGET_ROLE_ID = '1534935138440314960'; + export default { - data: new SlashCommandBuilder() - .setName("lock") - .setDescription( - "Locks the current channel (prevents @everyone from sending messages).", - ) -.setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels), - category: "moderation", + data: new SlashCommandBuilder() + .setName('lock') + .setDescription('Locks the current channel for the configured role.') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels), + + category: 'moderation', async execute(interaction, config, client) { const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) { - logger.warn(`Lock interaction defer failed`, { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'lock' - }); - return; - } + if (!deferSuccess) return; const channel = interaction.channel; - const everyoneRole = interaction.guild.roles.everyone; + const targetRole = interaction.guild.roles.cache.get(TARGET_ROLE_ID); + + if (!targetRole) { + return await replyUserError(interaction, { + type: ErrorTypes.UNKNOWN, + message: 'The configured role could not be found in this server.' + }); + } try { - const currentPermissions = channel.permissionsFor(everyoneRole); - if (currentPermissions.has(PermissionFlagsBits.SendMessages) === false) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `${channel} is already locked.` }); + const currentPermissions = channel.permissionsFor(targetRole); + + if (!currentPermissions.has(PermissionFlagsBits.SendMessages)) { + return await replyUserError(interaction, { + type: ErrorTypes.UNKNOWN, + message: `${channel} is already locked for ${targetRole}.` + }); } await channel.permissionOverwrites.edit( - everyoneRole, + targetRole, { SendMessages: false }, -{ type: 0, reason: `Channel locked by ${interaction.user.tag}` }, + { + type: 0, + reason: `Channel locked for ${targetRole.name} by ${interaction.user.tag}` + } ); await logEvent({ client, guild: interaction.guild, event: { - action: "Channel Locked", + action: 'Channel Locked', target: channel.toString(), executor: `${interaction.user.tag} (${interaction.user.id})`, metadata: { channelId: channel.id, - category: channel.parent?.name || 'None', + roleId: targetRole.id, + roleName: targetRole.name, moderatorId: interaction.user.id } } @@ -59,14 +67,19 @@ export default { await InteractionHelper.safeEditReply(interaction, { embeds: [ successEmbed( - `🔒 **Channel Locked**`, - `${channel} is now locked down. No one can speak here now.`, - ), - ], + '🔒 Channel Locked', + `${channel} is now locked for ${targetRole}.` + ) + ] }); + } catch (error) { logger.error('Lock command error:', error); - await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'An unexpected error occurred while trying to lock the channel. Check my permissions (I need \'Manage Channels\').' }); + + await replyUserError(interaction, { + type: ErrorTypes.PERMISSION, + message: 'I could not modify the channel permissions. Make sure I have Manage Channels.' + }); } } -}; \ No newline at end of file +}; diff --git a/src/commands/Moderation/mute.js b/src/commands/Moderation/mute.js new file mode 100644 index 0000000000..93b04f3bd0 --- /dev/null +++ b/src/commands/Moderation/mute.js @@ -0,0 +1,328 @@ +import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; +import { successEmbed } from '../../utils/embeds.js'; +import { logEvent } from '../../utils/moderation.js'; +import { logger } from '../../utils/logger.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; + +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const MUTED_ROLE_NAME = 'Muted'; +const MUTE_FILE = path.join(__dirname, '../../data/mutes.json'); + +function ensureMuteFile() { + const dir = path.dirname(MUTE_FILE); + + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + if (!fs.existsSync(MUTE_FILE)) { + fs.writeFileSync(MUTE_FILE, '{}', 'utf8'); + } +} + +function loadMutes() { + ensureMuteFile(); + + try { + return JSON.parse(fs.readFileSync(MUTE_FILE, 'utf8')); + } catch { + return {}; + } +} + +function saveMutes(mutes) { + ensureMuteFile(); + fs.writeFileSync( + MUTE_FILE, + JSON.stringify(mutes, null, 2), + 'utf8' + ); +} + +function parseDuration(duration) { + if (!duration) return null; + + const match = duration.toLowerCase().match( + /^(\d+)\s*(s|m|h|d|w)$/ + ); + + if (!match) return null; + + const amount = Number(match[1]); + const unit = match[2]; + + const units = { + s: 1000, + m: 60 * 1000, + h: 60 * 60 * 1000, + d: 24 * 60 * 60 * 1000, + w: 7 * 24 * 60 * 60 * 1000 + }; + + return amount * units[unit]; +} + +function formatDuration(ms) { + const seconds = Math.floor(ms / 1000); + + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + + const parts = []; + + if (days) parts.push(`${days}d`); + if (hours) parts.push(`${hours}h`); + if (minutes) parts.push(`${minutes}m`); + if (secs) parts.push(`${secs}s`); + + return parts.join(' ') || '0s'; +} + +async function scheduleUnmute(client, guildId, userId, expiresAt) { + const delay = expiresAt - Date.now(); + + if (delay <= 0) { + try { + const guild = await client.guilds.fetch(guildId); + const member = await guild.members.fetch(userId).catch(() => null); + + const mutedRole = guild.roles.cache.find( + role => role.name.toLowerCase() === MUTED_ROLE_NAME.toLowerCase() + ); + + if (member && mutedRole && member.roles.cache.has(mutedRole.id)) { + await member.roles.remove( + mutedRole, + 'Temporary mute expired' + ); + } + } catch (error) { + logger.error('Automatic unmute error:', error); + } + + const mutes = loadMutes(); + delete mutes[`${guildId}:${userId}`]; + saveMutes(mutes); + + return; + } + + setTimeout(async () => { + try { + const guild = await client.guilds.fetch(guildId); + const member = await guild.members.fetch(userId).catch(() => null); + + const mutedRole = guild.roles.cache.find( + role => role.name.toLowerCase() === MUTED_ROLE_NAME.toLowerCase() + ); + + if (member && mutedRole && member.roles.cache.has(mutedRole.id)) { + await member.roles.remove( + mutedRole, + 'Temporary mute expired' + ); + } + + const mutes = loadMutes(); + delete mutes[`${guildId}:${userId}`]; + saveMutes(mutes); + + logger.info( + `Temporary mute expired for ${userId} in ${guildId}` + ); + } catch (error) { + logger.error('Automatic unmute error:', error); + } + }, delay); +} + +async function restoreMutes(client) { + const mutes = loadMutes(); + + for (const [key, data] of Object.entries(mutes)) { + const [guildId, userId] = key.split(':'); + + if (!data?.expiresAt) continue; + + await scheduleUnmute( + client, + guildId, + userId, + data.expiresAt + ); + } +} + +export default { + data: new SlashCommandBuilder() + .setName('mute') + .setDescription('Mutes a member using the Muted role.') + .addUserOption(option => + option + .setName('user') + .setDescription('The member to mute.') + .setRequired(true) + ) + .addStringOption(option => + option + .setName('duration') + .setDescription('Mute duration: 10s, 10m, 2h, 1d, 1w. Leave empty for permanent.') + .setRequired(false) + ) + .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers), + + category: 'moderation', + + async execute(interaction, config, client) { + const deferSuccess = await InteractionHelper.safeDefer(interaction); + if (!deferSuccess) return; + + const user = interaction.options.getUser('user'); + const duration = interaction.options.getString('duration'); + + const member = await interaction.guild.members + .fetch(user.id) + .catch(() => null); + + if (!member) { + return await replyUserError(interaction, { + type: ErrorTypes.UNKNOWN, + message: 'This user is not in the server.' + }); + } + + const mutedRole = interaction.guild.roles.cache.find( + role => role.name.toLowerCase() === MUTED_ROLE_NAME.toLowerCase() + ); + + if (!mutedRole) { + return await replyUserError(interaction, { + type: ErrorTypes.UNKNOWN, + message: 'The `Muted` role could not be found.' + }); + } + + if (member.roles.cache.has(mutedRole.id)) { + return await replyUserError(interaction, { + type: ErrorTypes.UNKNOWN, + message: `${member} is already muted.` + }); + } + + if ( + member.id === interaction.guild.ownerId || + member.roles.highest.position >= interaction.member.roles.highest.position + ) { + return await replyUserError(interaction, { + type: ErrorTypes.PERMISSION, + message: 'You cannot mute this member because their highest role is equal to or higher than yours.' + }); + } + + if ( + mutedRole.position >= + interaction.guild.members.me.roles.highest.position + ) { + return await replyUserError(interaction, { + type: ErrorTypes.PERMISSION, + message: 'My role must be above the `Muted` role.' + }); + } + + let durationMs = null; + + if (duration) { + durationMs = parseDuration(duration); + + if (!durationMs) { + return await replyUserError(interaction, { + type: ErrorTypes.UNKNOWN, + message: 'Invalid duration. Use formats like `10s`, `10m`, `2h`, `1d`, or `1w`.' + }); + } + } + + try { + await member.roles.add( + mutedRole, + `Muted by ${interaction.user.tag}` + ); + + const mutes = loadMutes(); + const muteKey = `${interaction.guild.id}:${member.id}`; + + let expiresAt = null; + + if (durationMs) { + expiresAt = Date.now() + durationMs; + + mutes[muteKey] = { + guildId: interaction.guild.id, + userId: member.id, + expiresAt + }; + + saveMutes(mutes); + + await scheduleUnmute( + client, + interaction.guild.id, + member.id, + expiresAt + ); + } + + await logEvent({ + client, + guild: interaction.guild, + event: { + action: 'Member Muted', + target: `${member.user.tag} (${member.id})`, + executor: `${interaction.user.tag} (${interaction.user.id})`, + metadata: { + userId: member.id, + roleId: mutedRole.id, + roleName: mutedRole.name, + duration: duration || 'Permanent', + expiresAt, + moderatorId: interaction.user.id + } + } + }); + + const durationText = durationMs + ? `\nDuration: **${formatDuration(durationMs)}**` + : '\nDuration: **Permanent**'; + + await InteractionHelper.safeEditReply(interaction, { + embeds: [ + successEmbed( + '🔇 Member Muted', + `${member} has been muted successfully.${durationText}` + ) + ] + }); + + } catch (error) { + logger.error('Mute command error:', error); + + await replyUserError(interaction, { + type: ErrorTypes.PERMISSION, + message: 'I could not give the Muted role to this member. Check my Manage Roles permission and role position.' + }); + } + }, + + async restoreMutes(client) { + await restoreMutes(client); + } +}; diff --git a/src/commands/Moderation/unhide.js b/src/commands/Moderation/unhide.js new file mode 100644 index 0000000000..4ada738d1e --- /dev/null +++ b/src/commands/Moderation/unhide.js @@ -0,0 +1,77 @@ +import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; +import { successEmbed } from '../../utils/embeds.js'; +import { logEvent } from '../../utils/moderation.js'; +import { logger } from '../../utils/logger.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; + +const TARGET_ROLE_ID = '1534935138440314960'; + +export default { + data: new SlashCommandBuilder() + .setName('unhide') + .setDescription('Unhides the current channel for the configured role.') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels), + + category: 'moderation', + + async execute(interaction, config, client) { + const deferSuccess = await InteractionHelper.safeDefer(interaction); + if (!deferSuccess) return; + + const channel = interaction.channel; + const role = interaction.guild.roles.cache.get(TARGET_ROLE_ID); + + if (!role) { + return await replyUserError(interaction, { + type: ErrorTypes.UNKNOWN, + message: 'The configured role could not be found in this server.' + }); + } + + try { + await channel.permissionOverwrites.edit( + role, + { + ViewChannel: null + }, + { + type: 0, + reason: `Channel unhidden for ${role.name} by ${interaction.user.tag}` + } + ); + + await logEvent({ + client, + guild: interaction.guild, + event: { + action: 'Channel Unhidden', + target: channel.toString(), + executor: `${interaction.user.tag} (${interaction.user.id})`, + metadata: { + channelId: channel.id, + roleId: role.id, + roleName: role.name, + moderatorId: interaction.user.id + } + } + }); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [ + successEmbed( + '🔓 Channel Unhidden', + `${channel} is now visible to ${role}.` + ) + ] + }); + } catch (error) { + logger.error('Unhide command error:', error); + + await replyUserError(interaction, { + type: ErrorTypes.PERMISSION, + message: 'I could not modify the channel permissions. Make sure I have Manage Channels.' + }); + } + } +}; diff --git a/src/commands/Moderation/unlock.js b/src/commands/Moderation/unlock.js index 408c2b3b07..e9956f96fc 100644 --- a/src/commands/Moderation/unlock.js +++ b/src/commands/Moderation/unlock.js @@ -1,79 +1,85 @@ -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType } from 'discord.js'; -import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; +import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; +import { successEmbed } from '../../utils/embeds.js'; import { logEvent } from '../../utils/moderation.js'; import { logger } from '../../utils/logger.js'; -import { getColor } from '../../config/bot.js'; - import { InteractionHelper } from '../../utils/interactionHelper.js'; import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; + +const TARGET_ROLE_ID = '1534935138440314960'; + export default { - data: new SlashCommandBuilder() - .setName("unlock") - .setDescription( - "Unlocks the current channel (allows @everyone to send messages again).", - ) -.setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels), - category: "moderation", + data: new SlashCommandBuilder() + .setName('unlock') + .setDescription('Unlocks the current channel for the configured role.') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels), - async execute(interaction, config, client) { - const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) { - logger.warn(`Unlock interaction defer failed`, { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'unlock' - }); - return; - } + category: 'moderation', + + async execute(interaction, config, client) { + const deferSuccess = await InteractionHelper.safeDefer(interaction); + if (!deferSuccess) return; - const channel = interaction.channel; - const everyoneRole = interaction.guild.roles.everyone; + const channel = interaction.channel; + const targetRole = interaction.guild.roles.cache.get(TARGET_ROLE_ID); - try { - const currentPermissions = channel.permissionsFor(everyoneRole); - if ( - currentPermissions.has(PermissionFlagsBits.SendMessages) === - true || - currentPermissions.has(PermissionFlagsBits.SendMessages) === - null - ) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `${channel} is not explicitly locked (everyone can already send messages).` }); - } + if (!targetRole) { + return await replyUserError(interaction, { + type: ErrorTypes.UNKNOWN, + message: 'The configured role could not be found in this server.' + }); + } - await channel.permissionOverwrites.edit( - everyoneRole, - { SendMessages: true }, - { - type: 0, - reason: `Channel unlocked by ${interaction.user.tag}`, -}, - ); + try { + const currentPermissions = channel.permissionsFor(targetRole); - await logEvent({ - client, - guild: interaction.guild, - event: { - action: "Channel Unlocked", - target: channel.toString(), - executor: `${interaction.user.tag} (${interaction.user.id})`, - metadata: { - channelId: channel.id, - category: channel.parent?.name || 'None' - } - } - }); + if (currentPermissions.has(PermissionFlagsBits.SendMessages)) { + return await replyUserError(interaction, { + type: ErrorTypes.UNKNOWN, + message: `${channel} is already unlocked for ${targetRole}.` + }); + } - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - `🔓 **Channel Unlocked**`, - `${channel} is now unlocked. You may speak now.`, - ), - ], - }); - } catch (error) { - logger.error('Unlock command error:', error); - await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'An unexpected error occurred while trying to unlock the channel. Check my permissions (I need \'Manage Channels\').' }); + await channel.permissionOverwrites.edit( + targetRole, + { SendMessages: null }, + { + type: 0, + reason: `Channel unlocked for ${targetRole.name} by ${interaction.user.tag}` } + ); + + await logEvent({ + client, + guild: interaction.guild, + event: { + action: 'Channel Unlocked', + target: channel.toString(), + executor: `${interaction.user.tag} (${interaction.user.id})`, + metadata: { + channelId: channel.id, + roleId: targetRole.id, + roleName: targetRole.name, + moderatorId: interaction.user.id + } + } + }); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [ + successEmbed( + '🔓 Channel Unlocked', + `${channel} is now unlocked for ${targetRole}.` + ) + ] + }); + + } catch (error) { + logger.error('Unlock command error:', error); + + await replyUserError(interaction, { + type: ErrorTypes.PERMISSION, + message: 'I could not modify the channel permissions. Make sure I have Manage Channels.' + }); } -}; \ No newline at end of file + } +}; diff --git a/src/commands/Moderation/unmute.js b/src/commands/Moderation/unmute.js new file mode 100644 index 0000000000..6b81c2ff57 --- /dev/null +++ b/src/commands/Moderation/unmute.js @@ -0,0 +1,87 @@ +import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; +import { successEmbed } from '../../utils/embeds.js'; +import { logEvent } from '../../utils/moderation.js'; +import { logger } from '../../utils/logger.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; + +const MUTED_ROLE_NAME = 'muted'; + +export default { + data: new SlashCommandBuilder() + .setName('unmute') + .setDescription('Unmutes a member by removing the Muted role.') + .addUserOption(option => + option + .setName('user') + .setDescription('The member to unmute.') + .setRequired(true) + ) + .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers), + + category: 'moderation', + + async execute(interaction, config, client) { + const deferSuccess = await InteractionHelper.safeDefer(interaction); + if (!deferSuccess) return; + + const user = interaction.options.getUser('user'); + const member = await interaction.guild.members.fetch(user.id); + + const mutedRole = interaction.guild.roles.cache.find( + role => role.name.toLowerCase() === MUTED_ROLE_NAME.toLowerCase() + ); + + if (!mutedRole) { + return await replyUserError(interaction, { + type: ErrorTypes.UNKNOWN, + message: 'The `Muted` role could not be found.' + }); + } + + if (!member.roles.cache.has(mutedRole.id)) { + return await replyUserError(interaction, { + type: ErrorTypes.UNKNOWN, + message: `${member} is not muted.` + }); + } + + try { + await member.roles.remove( + mutedRole, + `Unmuted by ${interaction.user.tag}` + ); + + await logEvent({ + client, + guild: interaction.guild, + event: { + action: 'Member Unmuted', + target: `${member.user.tag} (${member.id})`, + executor: `${interaction.user.tag} (${interaction.user.id})`, + metadata: { + userId: member.id, + roleId: mutedRole.id, + moderatorId: interaction.user.id + } + } + }); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [ + successEmbed( + '🔊 Member Unmuted', + `${member} has been unmuted successfully.` + ) + ] + }); + } catch (error) { + logger.error('Unmute command error:', error); + + await replyUserError(interaction, { + type: ErrorTypes.PERMISSION, + message: 'I could not remove the Muted role from this member. Check my Manage Roles permission.' + }); + } + } +}; diff --git a/src/commands/Security/security.js b/src/commands/Security/security.js new file mode 100644 index 0000000000..319de3818b --- /dev/null +++ b/src/commands/Security/security.js @@ -0,0 +1,177 @@ +import { + SlashCommandBuilder, + PermissionFlagsBits, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + EmbedBuilder, + MessageFlags, +} from 'discord.js'; +import { getSecurityConfig } from '../../services/security/securityService.js'; + +const PANEL_META = { + nuke: ['🛡️ Anti-Nuke', 'Protects channels, roles, webhooks, bans and dangerous server changes.'], + raid: ['🚨 Anti-Raid', 'Detects rapid joins and suspicious new accounts.'], + automod: ['🤖 AutoMod', 'Stops spam, duplicates, mentions, invites, links and other abuse.'], + punishments: ['⚖️ Punishments', 'Every protection rule can have its own punishment.'], + strikes: ['🏆 Strike Board', 'See members with the most active security strikes and manage them.'], + whitelist: ['👤 Whitelist', 'Trusted users, roles and bots bypass security actions.'], + logs: ['📋 Logs', 'Choose where security incidents are reported.'], + settings: ['⚙️ Settings', 'Global protection and security behavior.'], +}; + +const MAIN_BUTTONS = [ + ['security_panel_nuke', '🛡️ Anti-Nuke', ButtonStyle.Danger], + ['security_panel_raid', '🚨 Anti-Raid', ButtonStyle.Primary], + ['security_panel_automod', '🤖 AutoMod', ButtonStyle.Primary], + ['security_panel_punishments', '⚖️ Punishments', ButtonStyle.Primary], + ['security_panel_strikes', '🏆 Strikes', ButtonStyle.Danger], + ['security_panel_whitelist', '👤 Whitelist', ButtonStyle.Secondary], + ['security_panel_logs', '📋 Logs', ButtonStyle.Secondary], + ['security_panel_settings', '⚙️ Settings', ButtonStyle.Secondary], +]; + +const NUKE_LABELS = { channelDelete: 'Channel Del', channelCreate: 'Channel Add', roleDelete: 'Role Del', roleCreate: 'Role Add', roleUpdate: 'Role Edit', webhookUpdate: 'Webhook Edit', webhookDelete: 'Webhook Del', ban: 'Ban', kick: 'Kick', botAdd: 'Bot Add' }; +const AUTOMOD_LABELS = { spam: 'Spam', duplicate: 'Duplicate', mentions: 'Mentions', invites: 'Invites', links: 'Links', caps: 'Caps', badWords: 'Bad Words' }; + +function status(enabled) { return enabled ? '🟢 **ACTIVE**' : '🔴 **OFF**'; } +function boolLabel(value) { return value ? '🟢 ON' : '🔴 OFF'; } +function hours(ms) { return Math.max(0, Math.round(Number(ms || 0) / 3600000)); } +function button(id, label, style = ButtonStyle.Secondary, disabled = false) { return new ButtonBuilder().setCustomId(id).setLabel(label).setStyle(style).setDisabled(disabled); } + +export function buildSecurityDashboard(config, guild) { + const systems = [config.enabled, config.antiNuke?.enabled, config.antiRaid?.enabled, config.autoMod?.enabled]; + const active = systems.filter(Boolean).length; + const score = Math.round((active / systems.length) * 100); + const scoreLabel = score >= 100 ? 'Maximum' : score >= 75 ? 'Strong' : score >= 50 ? 'Partial' : 'Weak'; + const whitelistCount = (config.whitelist?.users?.length || 0) + (config.whitelist?.roles?.length || 0) + (config.whitelist?.bots?.length || 0); + const logValue = config.logChannelId ? `<#${config.logChannelId}>` : '`Not configured`'; + return new EmbedBuilder() + .setAuthor({ name: 'Infinity Security Center', iconURL: guild.iconURL({ size: 128 }) || undefined }) + .setTitle('🛡️ Server Protection') + .setDescription(`**${guild.name}**\n\nCentralized security controls for your server.\n\n**Security Health:** ${score}% • **${scoreLabel}**`) + .setColor(score >= 100 ? 0x57f287 : score >= 75 ? 0xfee75c : score >= 50 ? 0xf47b67 : 0xed4245) + .setThumbnail(guild.iconURL({ size: 256 }) || null) + .addFields( + { name: '━━ Protection ━━', value: [`🛡️ Anti-Nuke ${status(config.antiNuke?.enabled)}`, `🚨 Anti-Raid ${status(config.antiRaid?.enabled)}`, `🤖 AutoMod ${status(config.autoMod?.enabled)}`, `🔒 Global ${status(config.enabled)}`].join('\n'), inline: true }, + { name: '━━ Statistics ━━', value: [`⚡ **${config.escalation?.length || 0}** escalation levels`, `🏆 **Strike management** enabled`, `👤 **${whitelistCount}** whitelist entries`, `📋 Logs: ${logValue}`].join('\n'), inline: true }, + { name: '━━ Current Mode ━━', value: config.enabled ? '🟢 **PROTECTED** — security systems are actively monitoring this server.' : '🔴 **DISABLED** — global security protection is currently off.' }, + ) + .setFooter({ text: 'Infinity System • Security Center • Changes save automatically' }) + .setTimestamp(); +} + +export function buildSecurityControls(userId) { + return [ + new ActionRowBuilder().addComponents(...MAIN_BUTTONS.slice(0, 4).map(([id, label, style]) => button(`${id}:${userId}`, label, style))), + new ActionRowBuilder().addComponents(...MAIN_BUTTONS.slice(4).map(([id, label, style]) => button(`${id}:${userId}`, label, style)), button(`security_refresh:${userId}`, '🔄 Refresh', ButtonStyle.Success)), + ]; +} + +export function buildSecurityPanel(config, guild, panel) { + const [title, description] = PANEL_META[panel] || PANEL_META.settings; + let data = []; + if (panel === 'nuke') data = [ + `**Status:** ${status(config.antiNuke?.enabled)}`, + `**Default action:** \`${config.antiNuke?.action || 'strip'}\``, + `**Detection window:** \`${Math.round((config.antiNuke?.windowMs || 10000) / 1000)}s\``, + `**Lockdown:** ${boolLabel(config.antiNuke?.lockdown)}`, + '', '**Thresholds**', + `Channels: delete \`${config.antiNuke?.thresholds?.channelDelete ?? 3}\` • create \`${config.antiNuke?.thresholds?.channelCreate ?? 5}\``, + `Roles: delete \`${config.antiNuke?.thresholds?.roleDelete ?? 3}\` • create \`${config.antiNuke?.thresholds?.roleCreate ?? 5}\` • edit \`${config.antiNuke?.thresholds?.roleUpdate ?? 1}\``, + `Webhooks: edit \`${config.antiNuke?.thresholds?.webhookUpdate ?? 3}\` • delete \`${config.antiNuke?.thresholds?.webhookDelete ?? 2}\``, + `Ban \`${config.antiNuke?.thresholds?.ban ?? 5}\` • Kick \`${config.antiNuke?.thresholds?.kick ?? 5}\` • Bot add \`${config.antiNuke?.thresholds?.botAdd ?? 1}\``, + ]; + else if (panel === 'raid') data = [ + `**Status:** ${status(config.antiRaid?.enabled)}`, + `**Punishment:** \`${config.antiRaid?.punishment || config.antiRaid?.action || 'timeout'}\``, + `**Join threshold:** \`${config.antiRaid?.joins ?? 8}\` members`, + `**Window:** \`${Math.round((config.antiRaid?.windowMs || 10000) / 1000)}s\``, + `**Minimum account age:** \`${hours(config.antiRaid?.minAccountAgeMs)}h\``, + `**Lockdown:** ${boolLabel(config.antiRaid?.lockdown)} • **Duration:** \`${Math.round((config.antiRaid?.lockdownMs || 600000) / 60000)}m\``, + ]; + else if (panel === 'automod') data = [ + `**Status:** ${status(config.autoMod?.enabled)}`, + `**Spam:** ${boolLabel(config.autoMod?.spam?.enabled)} • ${config.autoMod?.spam?.maxMessages ?? 6}/${Math.round((config.autoMod?.spam?.windowMs || 5000) / 1000)}s • **${config.autoMod?.spam?.punishment || 'timeout'}**`, + `**Duplicate:** ${boolLabel(config.autoMod?.duplicate?.enabled)} • ${config.autoMod?.duplicate?.maxRepeats ?? 3} repeats • **${config.autoMod?.duplicate?.punishment || 'timeout'}**`, + `**Mentions:** ${boolLabel(config.autoMod?.mentions?.enabled)} • max ${config.autoMod?.mentions?.max ?? 6} • **${config.autoMod?.mentions?.punishment || 'timeout'}**`, + `**Invites:** ${boolLabel(config.autoMod?.invites?.enabled)} • **${config.autoMod?.invites?.punishment || 'delete'}**`, + `**Links:** ${boolLabel(config.autoMod?.links?.enabled)} • **${config.autoMod?.links?.punishment || 'delete'}** • **Caps:** ${boolLabel(config.autoMod?.caps?.enabled)} • **${config.autoMod?.caps?.punishment || 'warn'}**`, + `**Bad words:** ${config.autoMod?.badWords?.words?.length || 0} words • **${config.autoMod?.badWords?.punishment || 'timeout'}**`, + ]; + else if (panel === 'punishments') { + const nuke = Object.entries(NUKE_LABELS).map(([key, label]) => `${label}: **${config.antiNuke?.punishments?.[key] || config.antiNuke?.action || 'strip'}**`); + const automod = Object.entries(AUTOMOD_LABELS).map(([key, label]) => `${label}: **${config.autoMod?.[key]?.punishment || 'delete'}**`); + data = ['**Per-rule punishments**', '', '**Anti-Nuke**', ...nuke, '', `**Anti-Raid:** **${config.antiRaid?.punishment || 'timeout'}**`, '', '**AutoMod**', ...automod, '', '**Escalation**', ...(config.escalation || []).slice(0, 10).map(e => `Strike ${e.strike}: **${e.action}**${e.durationMs ? ` • ${Math.round(e.durationMs / 60000)}m` : ''}`), '', `Strike decay: **${hours(config.strikeDecayMs)}h**`]; + } else if (panel === 'strikes') data = [ + '🏆 **Top active security strikes**', + '', + 'This board shows members with the highest active Strike count. Expired strikes are ignored automatically.', + '', + 'Use the buttons below to reset a member completely or refresh the board.', + ]; + else if (panel === 'whitelist') data = [`**Users:** \`${config.whitelist?.users?.length || 0}\``, `**Roles:** \`${config.whitelist?.roles?.length || 0}\``, `**Bots:** \`${config.whitelist?.bots?.length || 0}\``, '', 'Whitelisted accounts bypass Anti-Nuke and AutoMod enforcement where applicable.']; + else if (panel === 'logs') data = [`**Log channel:** ${config.logChannelId ? `<#${config.logChannelId}>` : '`Not configured`'}`, `**Ignored channels:** \`${config.ignoredChannels?.length || 0}\``, '', 'Security incidents include the executor/member, reason and action taken.']; + else data = [`**Global protection:** ${status(config.enabled)}`, `**Anti-Nuke:** ${status(config.antiNuke?.enabled)}`, `**Anti-Raid:** ${status(config.antiRaid?.enabled)}`, `**AutoMod:** ${status(config.autoMod?.enabled)}`, '', 'Use the controls below to change protection without leaving this message.']; + + return new EmbedBuilder() + .setAuthor({ name: 'Infinity Security Center', iconURL: guild.iconURL({ size: 128 }) || undefined }) + .setTitle(title) + .setDescription(`${description}\n\n${data.join('\n')}`) + .setColor(panel === 'nuke' ? 0xed4245 : panel === 'raid' ? 0xf47b67 : panel === 'automod' ? 0x5865f2 : panel === 'strikes' ? 0xfee75c : 0x57f287) + .setFooter({ text: 'Infinity System • Click a control below • Changes save automatically' }) + .setTimestamp(); +} + +export function buildStrikeBoardEmbed(guild, entries) { + const lines = entries.length + ? entries.map((entry, index) => `${index + 1}. <@${entry.userId}> — **${entry.count} strike${entry.count === 1 ? '' : 's'}**${entry.lastReason ? ` • ${String(entry.lastReason).slice(0, 80)}` : ''}`).join('\n') + : '✅ No active strikes found.'; + return new EmbedBuilder() + .setAuthor({ name: 'Infinity Security Center', iconURL: guild.iconURL({ size: 128 }) || undefined }) + .setTitle('🏆 Strike Leaderboard') + .setDescription(`**${guild.name}**\n\n${lines}`) + .setColor(0xfee75c) + .setFooter({ text: 'Resetting a strike only changes the security strike counter; security logs remain intact.' }) + .setTimestamp(); +} + +export function buildSecurityPanelControls(userId, panel, config) { + const id = name => `${name}:${userId}`; + const rows = []; + if (panel === 'nuke') { + rows.push(new ActionRowBuilder().addComponents(button(id('security_back'), '← Back'), button(id('security_nuke_toggle'), config.antiNuke.enabled ? '🟢 Disable' : '🔴 Enable', config.antiNuke.enabled ? ButtonStyle.Success : ButtonStyle.Danger), button(id('security_nuke_window'), `Window: ${Math.round(config.antiNuke.windowMs / 1000)}s`), button(id('security_nuke_lockdown'), `Lockdown: ${config.antiNuke.lockdown ? 'ON' : 'OFF'}`))); + rows.push(new ActionRowBuilder().addComponents(button(id('security_nuke_threshold'), 'Threshold Editor', ButtonStyle.Primary), button(id('security_panel_punishments'), '⚖️ Punishments', ButtonStyle.Primary))); + } else if (panel === 'raid') { + rows.push(new ActionRowBuilder().addComponents(button(id('security_back'), '← Back'), button(id('security_raid_toggle'), config.antiRaid.enabled ? '🟢 Disable' : '🔴 Enable', config.antiRaid.enabled ? ButtonStyle.Success : ButtonStyle.Danger), button(id('security_raid_joins_down'), '− Joins'), button(id('security_raid_joins_up'), '+ Joins'), button(id('security_raid_punishment'), `Punishment: ${config.antiRaid.punishment || 'timeout'}`, ButtonStyle.Primary))); + rows.push(new ActionRowBuilder().addComponents(button(id('security_raid_window'), `Window: ${Math.round(config.antiRaid.windowMs / 1000)}s`), button(id('security_raid_age'), `Age: ${hours(config.antiRaid.minAccountAgeMs)}h`), button(id('security_raid_lockdown'), `Lockdown: ${config.antiRaid.lockdown ? 'ON' : 'OFF'}`))); + } else if (panel === 'automod') { + rows.push(new ActionRowBuilder().addComponents(button(id('security_back'), '← Back'), button(id('security_automod_toggle'), config.autoMod.enabled ? '🟢 Disable' : '🔴 Enable', config.autoMod.enabled ? ButtonStyle.Success : ButtonStyle.Danger), button(id('security_automod_spam_toggle'), `Spam ${boolLabel(config.autoMod.spam.enabled)}`), button(id('security_automod_spam_punishment'), `Spam: ${config.autoMod.spam.punishment}`))); + rows.push(new ActionRowBuilder().addComponents(button(id('security_automod_dup_toggle'), `Duplicate ${boolLabel(config.autoMod.duplicate.enabled)}`), button(id('security_automod_dup_punishment'), `Duplicate: ${config.autoMod.duplicate.punishment}`), button(id('security_automod_mentions_punishment'), `Mentions: ${config.autoMod.mentions.punishment}`), button(id('security_automod_invites'), `Invites ${boolLabel(config.autoMod.invites.enabled)}`), button(id('security_automod_invites_punishment'), `Invites: ${config.autoMod.invites.punishment}`))); + rows.push(new ActionRowBuilder().addComponents(button(id('security_automod_links'), `Links ${boolLabel(config.autoMod.links.enabled)}`), button(id('security_automod_links_punishment'), `Links: ${config.autoMod.links.punishment}`), button(id('security_automod_caps'), `Caps ${boolLabel(config.autoMod.caps.enabled)}`), button(id('security_automod_caps_punishment'), `Caps: ${config.autoMod.caps.punishment}`))); + rows.push(new ActionRowBuilder().addComponents(button(id('security_automod_badwords'), '🚫 Manage Words', ButtonStyle.Primary), button(id('security_automod_badwords_punishment'), `Bad Words: ${config.autoMod.badWords.punishment}`, ButtonStyle.Primary), button(id('security_automod_spam_down'), 'Spam −'), button(id('security_automod_spam_up'), 'Spam +'), button(id('security_automod_action'), `Default: ${config.autoMod.action}`))); + } else if (panel === 'punishments') { + rows.push(new ActionRowBuilder().addComponents(button(id('security_back'), '← Back'), button(id('security_pun_decay_down'), 'Decay −'), button(id('security_pun_decay_up'), 'Decay +'), button(id('security_pun_raid'), `Raid: ${config.antiRaid.punishment}`, ButtonStyle.Primary))); + const nukeKeys = Object.keys(NUKE_LABELS); + for (let i = 0; i < nukeKeys.length; i += 5) rows.push(new ActionRowBuilder().addComponents(...nukeKeys.slice(i, i + 5).map(k => button(id(`security_pun_nuke_${k}`), `${NUKE_LABELS[k]}: ${config.antiNuke.punishments[k]}`, ButtonStyle.Primary)))); + const autoKeys = Object.keys(AUTOMOD_LABELS); + for (let i = 0; i < autoKeys.length; i += 5) rows.push(new ActionRowBuilder().addComponents(...autoKeys.slice(i, i + 5).map(k => button(id(`security_pun_auto_${k}`), `${AUTOMOD_LABELS[k]}: ${config.autoMod[k].punishment}`, ButtonStyle.Primary)))); + for (let i = 0; i < Math.min(10, (config.escalation || []).length); i += 5) rows.push(new ActionRowBuilder().addComponents(...config.escalation.slice(i, i + 5).map(level => button(id(`security_pun_level_${level.strike}`), `#${level.strike}: ${level.action}`, ButtonStyle.Secondary)))); + } else if (panel === 'strikes') { + rows.push(new ActionRowBuilder().addComponents(button(id('security_back'), '← Back'), button(id('security_strikes_refresh'), '🔄 Refresh', ButtonStyle.Success))); + } else if (panel === 'whitelist') rows.push(new ActionRowBuilder().addComponents(button(id('security_back'), '← Back'), button(id('security_whitelist_users'), '👤 Manage Users', ButtonStyle.Primary), button(id('security_whitelist_roles'), '🎭 Manage Roles', ButtonStyle.Primary), button(id('security_whitelist_bots'), '🤖 Manage Bots', ButtonStyle.Primary))); + else if (panel === 'logs') rows.push(new ActionRowBuilder().addComponents(button(id('security_back'), '← Back'), button(id('security_logs_channel'), '📋 Set Log Channel', ButtonStyle.Primary), button(id('security_logs_ignored'), '🚫 Ignored Channels'))); + else rows.push(new ActionRowBuilder().addComponents(button(id('security_back'), '← Back'), button(id('security_settings_toggle'), config.enabled ? '🟢 Disable Protection' : '🔴 Enable Protection', config.enabled ? ButtonStyle.Danger : ButtonStyle.Success), button(id('security_settings_refresh'), '🔄 Refresh'))); + return rows.slice(0, 5); +} + +export default { + category: 'Security', + slashOnly: true, + data: new SlashCommandBuilder().setName('security').setDescription('Open the server security dashboard').setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild.toString()), + async execute(interaction, config, client) { + if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) return interaction.reply({ content: 'You need Manage Server permission.', flags: MessageFlags.Ephemeral }); + const security = await getSecurityConfig(client, interaction.guildId); + await interaction.reply({ embeds: [buildSecurityDashboard(security, interaction.guild)], components: buildSecurityControls(interaction.user.id), flags: MessageFlags.Ephemeral }); + }, +}; \ No newline at end of file diff --git a/src/commands/Tools/countdown.js b/src/commands/Tools/countdown.js index d7561ddbf3..e830f48eb1 100644 --- a/src/commands/Tools/countdown.js +++ b/src/commands/Tools/countdown.js @@ -15,17 +15,17 @@ export default { .addIntegerOption((option) => option .setName("minutes") - .setDescription("Number of minutes to count down (0-1440)") + .setDescription("Number of minutes to count down (0-7200)") .setMinValue(0) - .setMaxValue(1440) + .setMaxValue(7200) .setRequired(false), ) .addIntegerOption((option) => option .setName("seconds") - .setDescription("Number of seconds to count down (0-59)") + .setDescription("Number of seconds to count down (0-432000)") .setMinValue(0) - .setMaxValue(59) + .setMaxValue(432000) .setRequired(false), ) .addStringOption((option) => @@ -56,8 +56,8 @@ export default { throw new Error("Please specify a duration of at least 1 second."); } - if (totalSeconds > 86400) { - throw new Error("Countdown cannot be longer than 24 hours."); + if (totalSeconds > 432000) { + throw new Error("Countdown cannot be longer than 5 days."); } const endTime = Date.now() + totalSeconds * 1000; @@ -93,4 +93,4 @@ export default { flags: MessageFlags.Ephemeral, }); }, -}; \ No newline at end of file +}; diff --git a/src/commands/Tools/randomuser.js b/src/commands/Tools/randomuser.js index 4dc15a6fd1..d7068212dc 100644 --- a/src/commands/Tools/randomuser.js +++ b/src/commands/Tools/randomuser.js @@ -49,6 +49,9 @@ export default { const onlineOnly = interaction.options.getBoolean('online') || false; const shouldMention = interaction.options.getBoolean('mention') || false; + // Fetch all server members before selecting + await interaction.guild.members.fetch(); + let members = interaction.guild.members.cache.filter(member => { if (member.user.bot && !includeBots) return false; @@ -92,13 +95,13 @@ export default { '🎲 Random User Selected', shouldMention ? `${selectedMember}` : `**${user.username}**` ) - .setThumbnail(user.displayAvatarURL({ dynamic: true, size: 256 })) - .addFields( - { name: 'Username', value: user.username, inline: true }, - { name: 'Bot', value: user.bot ? 'Yes' : 'No', inline: true }, - { name: `Roles (${roles.length})`, value: roles.length > 0 ? roles.slice(0, 5).join('') + (roles.length > 5 ? `+${roles.length - 5} more` : '') : 'No roles', inline: false } - ) - .setColor('primary'); + .setThumbnail(user.displayAvatarURL({ dynamic: true, size: 256 })) + .addFields( + { name: 'Username', value: user.username, inline: true }, + { name: 'Bot', value: user.bot ? 'Yes' : 'No', inline: true }, + { name: `Roles (${roles.length})`, value: roles.length > 0 ? roles.slice(0, 5).join('') + (roles.length > 5 ? `+${roles.length - 5} more` : '') : 'No roles', inline: false } + ) + .setColor('#FF0000'); const row = new ActionRowBuilder() .addComponents( @@ -120,6 +123,9 @@ export default { collector.on('collect', async (i) => { try { + // Fetch all server members before selecting again + await interaction.guild.members.fetch(); + let newMembers = interaction.guild.members.cache.filter(member => { if (member.user.bot && !includeBots) return false; @@ -158,13 +164,13 @@ export default { '🎲 Random User Selected', shouldMention ? `${newSelectedMember}` : `**${newUser.username}**` ) - .setThumbnail(newUser.displayAvatarURL({ dynamic: true, size: 256 })) - .addFields( - { name: 'Username', value: newUser.username, inline: true }, - { name: 'Bot', value: newUser.bot ? 'Yes' : 'No', inline: true }, - { name: `Roles (${newRoles.length})`, value: newRoles.length > 0 ? newRoles.slice(0, 5).join('') + (newRoles.length > 5 ? `+${newRoles.length - 5} more` : '') : 'No roles', inline: false } - ) - .setColor(newSelectedMember.displayHexColor || '#3498db'); + .setThumbnail(newUser.displayAvatarURL({ dynamic: true, size: 256 })) + .addFields( + { name: 'Username', value: newUser.username, inline: true }, + { name: 'Bot', value: newUser.bot ? 'Yes' : 'No', inline: true }, + { name: `Roles (${newRoles.length})`, value: newRoles.length > 0 ? newRoles.slice(0, 5).join('') + (newRoles.length > 5 ? `+${newRoles.length - 5} more` : '') : 'No roles', inline: false } + ) + .setColor(newSelectedMember.displayHexColor || '#3498db'); await i.update({ content: shouldMention ? `${newSelectedMember}, you've been chosen!` : null, @@ -190,4 +196,4 @@ export default { interaction.editReply({ components: [disabledRow] }).catch(console.error); }); }, -}; \ No newline at end of file +}; diff --git a/src/commands/Utility/partner.js b/src/commands/Utility/partner.js new file mode 100644 index 0000000000..91797182cb --- /dev/null +++ b/src/commands/Utility/partner.js @@ -0,0 +1,29 @@ +import { SlashCommandBuilder, PermissionFlagsBits, ChannelType } from 'discord.js'; +import { setupPartnerPanel, partnerDashboard } from '../../utils/partner.js'; + +export default { + data: new SlashCommandBuilder() + .setName('partner') + .setDescription('إدارة نظام الشراكات') + .addSubcommand(sub => sub + .setName('setup') + .setDescription('إعداد نظام الشراكات') + .addChannelOption(opt => opt + .setName('announcement_channel') + .setDescription('الروم الذي سيتم نشر الشراكة المقبولة فيه') + .addChannelTypes(ChannelType.GuildText) + .setRequired(true))) + .addSubcommand(sub => sub + .setName('dashboard') + .setDescription('فتح لوحة إدارة الشراكات')) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild), + + async execute(interaction) { + const subcommand = interaction.options.getSubcommand(); + if (subcommand === 'setup') { + const announcementChannel = interaction.options.getChannel('announcement_channel'); + return setupPartnerPanel(interaction, announcementChannel); + } + return partnerDashboard(interaction); + }, +}; diff --git a/src/commands/Utility/suggestions.js b/src/commands/Utility/suggestions.js new file mode 100644 index 0000000000..118e05df65 --- /dev/null +++ b/src/commands/Utility/suggestions.js @@ -0,0 +1,26 @@ +import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; +import { setupSuggestions } from '../../utils/suggestions.js'; + +export default { + data: new SlashCommandBuilder() + .setName('suggestions') + .setDescription('Manage the server suggestions system') + .addSubcommand(sub => sub + .setName('setup') + .setDescription('Create the suggestions channel and panel')) + .addSubcommand(sub => sub + .setName('dashboard') + .setDescription('Open the suggestions management dashboard')) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild), + + async execute(interaction) { + const subcommand = interaction.options.getSubcommand(); + if (subcommand === 'setup') { + return setupSuggestions(interaction); + } + return interaction.reply({ + content: 'The suggestions dashboard will be available after setup.', + ephemeral: true, + }); + }, +}; diff --git a/src/config/bot.js b/src/config/bot.js index 86dc861cc3..68cb6259ae 100644 --- a/src/config/bot.js +++ b/src/config/bot.js @@ -24,7 +24,7 @@ export const botConfig = { activities: [ { name: "Custom Status", // required by Discord API, not shown in the client - state: "stalking", // this is what people actually see + state: "Watching You", // this is what people actually see type: 4, // Custom }, ], @@ -648,4 +648,4 @@ export function getRandomColor() { return colors[Math.floor(Math.random() * colors.length)]; } -export default botConfig; \ No newline at end of file +export default botConfig; diff --git a/src/config/guild/guildConfigDefaults.js b/src/config/guild/guildConfigDefaults.js index ef60a90be6..82e0bb278f 100644 --- a/src/config/guild/guildConfigDefaults.js +++ b/src/config/guild/guildConfigDefaults.js @@ -12,4 +12,9 @@ export const GUILD_CONFIG_DEFAULTS = { dmOnClose: true, disabledCommands: {}, disabledCategories: {}, + autoReaction: { + enabled: false, + channelId: null, + reaction: null, + }, }; diff --git a/src/config/music/lavalink.js b/src/config/music/lavalink.js index e464557a9e..7889b6c6bd 100644 --- a/src/config/music/lavalink.js +++ b/src/config/music/lavalink.js @@ -78,7 +78,7 @@ export function getLavalinkNodes() { export const lavalinkConfig = { nodes: getLavalinkNodes(), - defaultSearchPlatform: process.env.LAVALINK_SEARCH_PLATFORM || 'ytmsearch', + defaultSearchPlatform: process.env.LAVALINK_SEARCH_PLATFORM || 'ytsearch', restVersion: process.env.LAVALINK_REST_VERSION || 'v4', }; diff --git a/src/events/messageCreate.js b/src/events/messageCreate.js index fb1a165f31..7a3fea57b5 100644 --- a/src/events/messageCreate.js +++ b/src/events/messageCreate.js @@ -12,6 +12,8 @@ import { getCommandPrefix, getBotMessage, isBotOwner, isCommandCategoryEnabled, import { enforceAbuseProtection, formatCooldownDuration } from '../utils/abuseProtection.js'; import { createEmbed } from '../utils/embeds.js'; import { isCommandEnabled } from '../services/commandAccessService.js'; +import { processAutoMod } from '../services/security/securityService.js'; +import { getStaffData, incrementStaffActivity, recordTicketLog } from '../services/staffService.js'; import { getCountingGameConfig, saveCountingGameConfig, @@ -21,22 +23,44 @@ import { const MESSAGE_XP_RATE_LIMIT_ATTEMPTS = 12; const MESSAGE_XP_RATE_LIMIT_WINDOW_MS = 10000; +const STAFF_CACHE_TTL_MS = 60 * 1000; +const staffCache = new Map(); export default { name: Events.MessageCreate, async execute(message, client) { try { - if (message.author.bot || !message.guild) return; + if (!message.guild) return; + + // Ticket bot integration: read only the ticket bot's closed-ticket embed. + // This runs before the normal bot-message early return so messages from the + // separate ticket bot can update Staff > Tickets Handled. + if (message.author.bot) { + await handleTicketClosedLog(message); + return; + } + + // Count every message sent by a tracked staff member. Bot messages are + // excluded above, and the 60-second cache avoids a database read per message. + await trackStaffMessage(message); + + // Exact-match auto replies run across every guild channel. + const autoReplied = await handleAutoReply(message, client); + if (autoReplied) return; + + // Auto reaction: only the configured channel is monitored. Bot messages + // are excluded above, so the bot can never react to its own reaction flow. + await handleAutoReaction(message, client); + + const autoModTriggered = await processAutoMod(message, client); + if (autoModTriggered) return; logger.debug(`Message received from ${message.author.tag}: ${message.content}`); const countingProcessed = await handleCountingGame(message, client); - if (countingProcessed) { - return; - } + if (countingProcessed) return; await handlePrefixCommand(message, client); - await handleLeveling(message, client); } catch (error) { logger.error('Error in messageCreate event:', error); @@ -44,15 +68,128 @@ export default { } }; +async function handleAutoReply(message, client) { + try { + const content = String(message.content ?? ''); + if (!content) return false; + + const config = await getGuildConfig(client, message.guild.id); + const rules = Array.isArray(config?.autoReplies) ? config.autoReplies : []; + if (!rules.length) return false; + + const rule = rules.find((item) => String(item?.trigger ?? '') === content); + if (!rule || !String(rule.response ?? '').trim()) return false; + + await message.channel.send({ content: String(rule.response).slice(0, 2000) }); + return true; + } catch (error) { + logger.error('Error handling auto reply:', error); + return false; + } +} + +async function handleAutoReaction(message, client) { + try { + const config = await getGuildConfig(client, message.guild.id); + const autoReaction = config?.autoReaction; + if (!autoReaction?.enabled || !autoReaction.channelId || !autoReaction.reaction) return false; + if (message.channel.id !== autoReaction.channelId) return false; + + await message.react(autoReaction.reaction); + return true; + } catch (error) { + logger.error('Error handling auto reaction:', { + error: error.message, + guildId: message.guild?.id, + channelId: message.channel?.id, + messageId: message.id, + }); + return false; + } +} + +async function trackStaffMessage(message) { + try { + const now = Date.now(); + let cached = staffCache.get(message.guild.id); + + if (!cached || now - cached.updatedAt >= STAFF_CACHE_TTL_MS) { + const data = await getStaffData(message.guild.id); + cached = { + updatedAt: now, + staffIds: new Set(Object.keys(data.members || {})), + }; + staffCache.set(message.guild.id, cached); + } + + if (!cached.staffIds.has(message.author.id)) return; + await incrementStaffActivity(message.guild.id, message.author.id, 'messages'); + } catch (error) { + logger.error('Error tracking staff message activity:', error); + } +} + +async function handleTicketClosedLog(message) { + try { + if (!message.embeds?.length) return; + + const embed = message.embeds.find((item) => item.title?.trim() === 'تم إغلاق تذكرة'); + if (!embed) return; + + const fields = Array.isArray(embed.fields) ? embed.fields : []; + const getField = (...names) => { + const field = fields.find((item) => names.includes(String(item.name || '').trim())); + return field?.value?.trim() || null; + }; + + const channelName = getField('اسم القناة'); + const claimedBy = getField('مستلم التذكرة'); + const closedBy = getField('تم الإغلاق بواسطة'); + if (!channelName || !claimedBy) return; + + if (/لم\s*يتم\s*استلامها/i.test(claimedBy)) return; + + const mentionMatch = claimedBy.match(/<@!?([0-9]{15,25})>/); + const idMatch = claimedBy.match(/\b([0-9]{15,25})\b/); + const staffId = mentionMatch?.[1] || idMatch?.[1]; + if (!staffId) { + logger.warn(`Ticket log found but claimed staff ID could not be parsed: ${claimedBy}`, { + guildId: message.guild.id, + messageId: message.id, + }); + return; + } + + const ticketType = channelName.split('-')[0] || null; + const result = await recordTicketLog(message.guild.id, { + messageId: message.id, + staffId, + ticketId: channelName, + ticketType, + closedBy, + closedAt: embed.timestamp || message.createdAt?.toISOString() || null, + }); + + if (result.recorded) { + logger.info(`Ticket handled recorded for staff ${staffId}: ${channelName}`, { + event: 'staff.ticket_handled', + guildId: message.guild.id, + staffId, + ticketId: channelName, + sourceMessageId: message.id, + }); + } + } catch (error) { + logger.error('Error processing ticket closed log:', error); + } +} + async function handlePrefixCommand(message, client) { try { const guildConfig = await getGuildConfig(client, message.guild.id); const prefix = guildConfig?.prefix || getCommandPrefix(); const parsed = parsePrefixCommand(message.content, prefix); - - if (!parsed) { - return; - } + if (!parsed) return; let { commandName, args } = parsed; const musicPrefixShortcut = commandName.toLowerCase(); @@ -63,83 +200,39 @@ async function handlePrefixCommand(message, client) { } logger.info(`Prefix command detected: ${commandName}, args: ${args.join(', ')}`); - const resolvedCommandName = resolveCommandAlias(commandName); logger.info(`Resolved command name: ${resolvedCommandName}`); const command = client.commands.get(resolvedCommandName); - - if (!command) { - logger.warn(`Command not found: ${resolvedCommandName}`); - return; - } + if (!command) return; if (isMaintenanceMode() && !isBotOwner(message.author.id)) { - await message.channel.send({ - embeds: [createEmbed({ - title: 'Maintenance Mode', - description: getBotMessage('maintenanceMode'), - color: 'warning', - })], - }).catch(() => {}); + await message.channel.send({ embeds: [createEmbed({ title: 'Maintenance Mode', description: getBotMessage('maintenanceMode'), color: 'warning' })] }).catch(() => {}); return; } - if (!isCommandCategoryEnabled(command.category)) { - await message.channel.send({ - embeds: [createEmbed({ - title: 'Feature Disabled', - description: getBotMessage('commandDisabled'), - color: 'error', - })], - }).catch(() => {}); + await message.channel.send({ embeds: [createEmbed({ title: 'Feature Disabled', description: getBotMessage('commandDisabled'), color: 'error' })] }).catch(() => {}); return; } const restriction = getPrefixRestriction(command, args, resolveSubcommandAlias); if (!supportsPrefixExecution(command) || restriction.blocked) { - if (restriction.blocked && restriction.reason) { - const embed = createEmbed({ - title: 'Slash Command Only', - description: `${restriction.reason}\nUse \`/${resolvedCommandName}\` instead.`, - color: 'info', - }); - await message.channel.send({ embeds: [embed] }).catch(() => {}); - } + if (restriction.blocked && restriction.reason) await message.channel.send({ embeds: [createEmbed({ title: 'Slash Command Only', description: `${restriction.reason}\nUse \`/${resolvedCommandName}\` instead.`, color: 'info' })] }).catch(() => {}); return; } - if (!(await isCommandEnabled(client, message.guild.id, resolvePrefixAccessKey(command.data, args), command.category))) { - const embed = createEmbed({ - title: 'Command Disabled', - description: 'This command has been disabled for this server.', - color: 'error', - }); - await message.channel.send({ embeds: [embed] }).catch(() => {}); + await message.channel.send({ embeds: [createEmbed({ title: 'Command Disabled', description: 'This command has been disabled for this server.', color: 'error' })] }).catch(() => {}); return; } - const mockInteractionForProtection = { - guildId: message.guild.id, - user: message.author, - }; - const abuseProtection = await enforceAbuseProtection( - mockInteractionForProtection, - command, - resolvedCommandName, - ); + const mockInteractionForProtection = { guildId: message.guild.id, user: message.author }; + const abuseProtection = await enforceAbuseProtection(mockInteractionForProtection, command, resolvedCommandName); if (!abuseProtection.allowed) { const formattedCooldown = formatCooldownDuration(abuseProtection.remainingMs); - const embed = createEmbed({ - title: 'Command Cooldown', - description: `This command is on cooldown. Please wait ${formattedCooldown} before trying again.`, - color: 'error', - }); - await message.channel.send({ embeds: [embed] }).catch(() => {}); + await message.channel.send({ embeds: [createEmbed({ title: 'Command Cooldown', description: `This command is on cooldown. Please wait ${formattedCooldown} before trying again.`, color: 'error' })] }).catch(() => {}); return; } logger.info(`Executing prefix command: ${prefix}${commandName} (resolved to ${resolvedCommandName}) by ${message.author.tag}`); - await executePrefixCommand(command, message, args, client, prefix, guildConfig); } catch (error) { logger.error('Error handling prefix command:', error); @@ -149,31 +242,18 @@ async function handlePrefixCommand(message, client) { async function handleCountingGame(message, client) { try { const config = await getCountingGameConfig(client, message.guild.id); - if (!config.enabled || !config.channelId || message.channel.id !== config.channelId) { - return false; - } + if (!config.enabled || !config.channelId || message.channel.id !== config.channelId) return false; const content = message.content.trim(); const validCount = isValidCountingMessage(content, config); const invalidAttempt = !validCount || message.author.id === config.lastUserId; - if (invalidAttempt) { await message.delete().catch(() => {}); - await saveCountingGameConfig(client, message.guild.id, { - ...config, - nextNumber: 1, - lastUserId: null, - currentStreak: 0, - }); - + await saveCountingGameConfig(client, message.guild.id, { ...config, nextNumber: 1, lastUserId: null, currentStreak: 0 }); const failureMessage = await message.channel.send(`❌ Count broken by <@${message.author.id}>. The sequence has been reset to **1**.`); - setTimeout(() => { - failureMessage.delete().catch(() => {}); - }, 10000); - + setTimeout(() => failureMessage.delete().catch(() => {}), 10000); return true; } - await recordCorrectCount(client, message.guild.id, message.author.id); return true; } catch (error) { @@ -186,68 +266,31 @@ async function handleLeveling(message, client) { try { const rateLimitKey = `xp-event:${message.guild.id}:${message.author.id}`; const canProcess = await checkRateLimit(rateLimitKey, MESSAGE_XP_RATE_LIMIT_ATTEMPTS, MESSAGE_XP_RATE_LIMIT_WINDOW_MS); - if (!canProcess) { - return; - } + if (!canProcess) return; const levelingConfig = await getLevelingConfig(client, message.guild.id); - - if (!levelingConfig?.enabled) { - return; - } - - if (levelingConfig.ignoredChannels?.includes(message.channel.id)) { - return; - } - + if (!levelingConfig?.enabled) return; + if (levelingConfig.ignoredChannels?.includes(message.channel.id)) return; if (levelingConfig.ignoredRoles?.length > 0) { - const member = await message.guild.members.fetch(message.author.id).catch(() => { - return null; - }); - if (member && member.roles.cache.some(role => levelingConfig.ignoredRoles.includes(role.id))) { - return; - } - } - - if (levelingConfig.blacklistedUsers?.includes(message.author.id)) { - return; - } - - if (!message.content || message.content.trim().length === 0) { - return; + const member = await message.guild.members.fetch(message.author.id).catch(() => null); + if (member && member.roles.cache.some(role => levelingConfig.ignoredRoles.includes(role.id))) return; } + if (levelingConfig.blacklistedUsers?.includes(message.author.id)) return; + if (!message.content || message.content.trim().length === 0) return; const userData = await getUserLevelData(client, message.guild.id, message.author.id); - const cooldownTime = levelingConfig.xpCooldown || 60; - const now = Date.now(); - const timeSinceLastMessage = now - (userData.lastMessage || 0); - - if (timeSinceLastMessage < cooldownTime * 1000) { - return; - } + if (Date.now() - (userData.lastMessage || 0) < cooldownTime * 1000) return; const minXP = levelingConfig.xpRange?.min || levelingConfig.xpPerMessage?.min || 15; const maxXP = levelingConfig.xpRange?.max || levelingConfig.xpPerMessage?.max || 25; - const safeMinXP = Math.max(1, minXP); const safeMaxXP = Math.max(safeMinXP, maxXP); - const xpToGive = Math.floor(Math.random() * (safeMaxXP - safeMinXP + 1)) + safeMinXP; - - let finalXP = xpToGive; - if (levelingConfig.xpMultiplier && levelingConfig.xpMultiplier > 1) { - finalXP = Math.floor(finalXP * levelingConfig.xpMultiplier); - } - + const finalXP = levelingConfig.xpMultiplier && levelingConfig.xpMultiplier > 1 ? Math.floor(xpToGive * levelingConfig.xpMultiplier) : xpToGive; const result = await addXp(client, message.guild, message.member, finalXP); - - if (result?.leveledUp) { - logger.info( - `${message.author.tag} leveled up to level ${result.level} in ${message.guild.name}` - ); - } + if (result?.leveledUp) logger.info(`${message.author.tag} leveled up to level ${result.level} in ${message.guild.name}`); } catch (error) { logger.error('Error handling leveling for message:', error); } -} \ No newline at end of file +} diff --git a/src/events/securityBanAdd.js b/src/events/securityBanAdd.js new file mode 100644 index 0000000000..23315ce269 --- /dev/null +++ b/src/events/securityBanAdd.js @@ -0,0 +1,9 @@ +import { Events } from 'discord.js'; +import { handleAntiNuke } from '../services/security/antiNuke.js'; + +export default { + name: Events.GuildBanAdd, + async execute(ban) { + await handleAntiNuke(ban.guild, 'ban', ban.user.id); + }, +}; diff --git a/src/events/securityBotAdd.js b/src/events/securityBotAdd.js new file mode 100644 index 0000000000..7e9d486555 --- /dev/null +++ b/src/events/securityBotAdd.js @@ -0,0 +1,10 @@ +import { Events } from 'discord.js'; +import { handleAntiNuke } from '../services/security/antiNuke.js'; + +export default { + name: Events.GuildMemberAdd, + async execute(member) { + if (!member?.user?.bot) return; + await handleAntiNuke(member.guild, 'botAdd', member.id); + }, +}; diff --git a/src/events/securityChannelCreate.js b/src/events/securityChannelCreate.js new file mode 100644 index 0000000000..e6aa5292e3 --- /dev/null +++ b/src/events/securityChannelCreate.js @@ -0,0 +1,9 @@ +import { Events } from 'discord.js'; +import { handleAntiNuke } from '../services/security/antiNuke.js'; + +export default { + name: Events.ChannelCreate, + async execute(channel) { + if (channel.guild) await handleAntiNuke(channel.guild, 'channelCreate', channel.id); + }, +}; diff --git a/src/events/securityChannelDelete.js b/src/events/securityChannelDelete.js new file mode 100644 index 0000000000..ce14e2c66f --- /dev/null +++ b/src/events/securityChannelDelete.js @@ -0,0 +1,9 @@ +import { Events } from 'discord.js'; +import { handleAntiNuke } from '../services/security/antiNuke.js'; + +export default { + name: Events.ChannelDelete, + async execute(channel) { + if (channel.guild) await handleAntiNuke(channel.guild, 'channelDelete', channel.id); + }, +}; diff --git a/src/events/securityDashboardReady.js b/src/events/securityDashboardReady.js new file mode 100644 index 0000000000..8e77841d84 --- /dev/null +++ b/src/events/securityDashboardReady.js @@ -0,0 +1,51 @@ +import { Events, PermissionFlagsBits } from 'discord.js'; +import express from 'express'; +import { registerSecurityDashboard } from '../services/security/securityDashboard.js'; +import { logger, startupLog } from '../utils/logger.js'; + +export default { + name: Events.ClientReady, + once: true, + async execute(client) { + try { + const server = client.webServer; + const app = server?.listeners('request')?.[0]; + if (typeof app !== 'function') { + logger.warn('Security dashboard could not attach: Express app was not found.'); + } else { + app.use(express.json({ limit: '128kb' })); + registerSecurityDashboard(app, client); + } + + for (const guild of client.guilds.cache.values()) { + const me = guild.members.me; + const permissions = me?.permissions; + if (!permissions) continue; + + const required = [ + [PermissionFlagsBits.ViewAuditLog, 'View Audit Log'], + [PermissionFlagsBits.ManageChannels, 'Manage Channels'], + [PermissionFlagsBits.ManageRoles, 'Manage Roles'], + [PermissionFlagsBits.ManageWebhooks, 'Manage Webhooks'], + [PermissionFlagsBits.KickMembers, 'Kick Members'], + [PermissionFlagsBits.BanMembers, 'Ban Members'], + [PermissionFlagsBits.ModerateMembers, 'Moderate Members'], + ]; + const missing = required.filter(([bit]) => !permissions.has(bit)).map(([, name]) => name); + if (missing.length) { + logger.warn(`Security permissions missing in ${guild.name}: ${missing.join(', ')}`); + } else { + startupLog(`Security permissions OK in ${guild.name}`); + } + } + + if (!process.env.SECURITY_DASHBOARD_TOKEN) { + logger.warn('SECURITY_DASHBOARD_TOKEN is not configured; /security will be inaccessible.'); + } else { + startupLog('Security dashboard enabled at /security'); + } + } catch (error) { + logger.error('Failed to initialize security dashboard:', error); + } + }, +}; diff --git a/src/events/securityMemberAdd.js b/src/events/securityMemberAdd.js new file mode 100644 index 0000000000..d557a3b30d --- /dev/null +++ b/src/events/securityMemberAdd.js @@ -0,0 +1,9 @@ +import { Events } from 'discord.js'; +import { handleMemberJoin } from '../services/security/antiRaid.js'; + +export default { + name: Events.GuildMemberAdd, + async execute(member) { + await handleMemberJoin(member); + }, +}; diff --git a/src/events/securityMemberRemove.js b/src/events/securityMemberRemove.js new file mode 100644 index 0000000000..5ef8b9a138 --- /dev/null +++ b/src/events/securityMemberRemove.js @@ -0,0 +1,9 @@ +import { Events } from 'discord.js'; +import { handleAntiNuke } from '../services/security/antiNuke.js'; + +export default { + name: Events.GuildMemberRemove, + async execute(member) { + await handleAntiNuke(member.guild, 'kick', member.id); + }, +}; diff --git a/src/events/securityMessageCreate.js b/src/events/securityMessageCreate.js new file mode 100644 index 0000000000..0d07de21c3 --- /dev/null +++ b/src/events/securityMessageCreate.js @@ -0,0 +1,9 @@ +import { Events } from 'discord.js'; +import { handleAutoMod } from '../services/security/autoMod.js'; + +export default { + name: Events.MessageCreate, + async execute(message) { + await handleAutoMod(message); + }, +}; diff --git a/src/events/securityMessageUpdate.js b/src/events/securityMessageUpdate.js new file mode 100644 index 0000000000..82f2f3eadc --- /dev/null +++ b/src/events/securityMessageUpdate.js @@ -0,0 +1,11 @@ +import { Events } from 'discord.js'; +import { handleAutoMod } from '../services/security/autoMod.js'; + +export default { + name: Events.MessageUpdate, + async execute(oldMessage, newMessage) { + if (!newMessage?.guild || newMessage.author?.bot) return; + if (!newMessage.content || newMessage.content === oldMessage?.content) return; + await handleAutoMod(newMessage); + }, +}; diff --git a/src/events/securityRoleCreate.js b/src/events/securityRoleCreate.js new file mode 100644 index 0000000000..67c6e7a035 --- /dev/null +++ b/src/events/securityRoleCreate.js @@ -0,0 +1,9 @@ +import { Events } from 'discord.js'; +import { handleAntiNuke } from '../services/security/antiNuke.js'; + +export default { + name: Events.RoleCreate, + async execute(role) { + if (role.guild) await handleAntiNuke(role.guild, 'roleCreate', role.id); + }, +}; diff --git a/src/events/securityRoleDelete.js b/src/events/securityRoleDelete.js new file mode 100644 index 0000000000..fa68275221 --- /dev/null +++ b/src/events/securityRoleDelete.js @@ -0,0 +1,9 @@ +import { Events } from 'discord.js'; +import { handleAntiNuke } from '../services/security/antiNuke.js'; + +export default { + name: Events.RoleDelete, + async execute(role) { + if (role.guild) await handleAntiNuke(role.guild, 'roleDelete', role.id); + }, +}; diff --git a/src/events/securityRoleUpdate.js b/src/events/securityRoleUpdate.js new file mode 100644 index 0000000000..c9825d7a61 --- /dev/null +++ b/src/events/securityRoleUpdate.js @@ -0,0 +1,26 @@ +import { Events, PermissionFlagsBits } from 'discord.js'; +import { handleAntiNuke } from '../services/security/antiNuke.js'; + +const DANGEROUS_PERMISSIONS = [ + PermissionFlagsBits.Administrator, + PermissionFlagsBits.ManageGuild, + PermissionFlagsBits.ManageChannels, + PermissionFlagsBits.ManageRoles, + PermissionFlagsBits.BanMembers, + PermissionFlagsBits.KickMembers, + PermissionFlagsBits.ManageWebhooks, +]; + +function hasDangerousPermission(role) { + return DANGEROUS_PERMISSIONS.some(permission => role.permissions.has(permission)); +} + +export default { + name: Events.RoleUpdate, + async execute(oldRole, newRole) { + if (!newRole?.guild) return; + if (!hasDangerousPermission(oldRole) && hasDangerousPermission(newRole)) { + await handleAntiNuke(newRole.guild, 'roleUpdate', newRole.id); + } + }, +}; diff --git a/src/events/securityWebhooksUpdate.js b/src/events/securityWebhooksUpdate.js new file mode 100644 index 0000000000..c8aeddc59f --- /dev/null +++ b/src/events/securityWebhooksUpdate.js @@ -0,0 +1,10 @@ +import { Events } from 'discord.js'; +import { handleAntiNuke } from '../services/security/antiNuke.js'; + +export default { + name: Events.WebhooksUpdate, + async execute(channel) { + if (!channel?.guild) return; + await handleAntiNuke(channel.guild, 'webhookUpdate', channel.id); + }, +}; diff --git a/src/events/staffActivity.js b/src/events/staffActivity.js new file mode 100644 index 0000000000..56f986d1cb --- /dev/null +++ b/src/events/staffActivity.js @@ -0,0 +1,20 @@ +import { Events, PermissionFlagsBits } from 'discord.js'; +import { getStaffData, incrementStaffActivity } from '../services/staffService.js'; + +export default { + name: Events.MessageCreate, + async execute(message) { + if (!message.guild || message.author.bot) return; + + const member = message.member; + if (!member) return; + + const data = await getStaffData(message.guild.id); + const isManager = data.config.managerRoleId && member.roles.cache.has(data.config.managerRoleId); + const isGuildManager = member.permissions.has(PermissionFlagsBits.ManageGuild); + + if (!isManager && !isGuildManager) return; + + await incrementStaffActivity(message.guild.id, message.author.id, 'messages', 1); + }, +}; diff --git a/src/events/temporaryVoicePanel.js b/src/events/temporaryVoicePanel.js new file mode 100644 index 0000000000..f10439f764 --- /dev/null +++ b/src/events/temporaryVoicePanel.js @@ -0,0 +1,41 @@ +import { getTemporaryChannelInfo } from '../utils/database.js'; +import { updatePanel } from '../services/temporaryVoicePanelService.js'; +import { logger } from '../utils/logger.js'; + +export default { + name: 'voiceStateUpdate', + once: false, + async execute(oldState, newState, client) { + try { + const guild = newState.guild || oldState.guild; + if (!guild || !client || newState.member?.user?.bot) return; + + if (newState.channelId && newState.channel) { + // The main JTC listener registers the temporary channel before moving + // the member. We intentionally re-check after a short delay so listener + // ordering/race conditions cannot prevent the public panel from appearing. + setTimeout(async () => { + try { + const info = await getTemporaryChannelInfo(client, guild.id, newState.channelId); + if (info) await updatePanel(client, newState.channel); + } catch (error) { + logger.debug(`Temporary voice panel sync failed: ${error.message}`); + } + }, 400); + } + + if (oldState.channelId && oldState.channelId !== newState.channelId && oldState.channel) { + setTimeout(async () => { + try { + const info = await getTemporaryChannelInfo(client, guild.id, oldState.channelId); + if (info) await updatePanel(client, oldState.channel); + } catch { + // The main JTC handler may already have deleted the empty room. + } + }, 600); + } + } catch (error) { + logger.debug(`Temporary voice panel event error: ${error.message}`); + } + } +}; diff --git a/src/events/temporaryVoicePanelInteraction.js b/src/events/temporaryVoicePanelInteraction.js new file mode 100644 index 0000000000..f34835a171 --- /dev/null +++ b/src/events/temporaryVoicePanelInteraction.js @@ -0,0 +1,180 @@ +import { Events, MessageFlags } from 'discord.js'; +import { getTemporaryChannelInfo } from '../utils/database.js'; +import { + buildUserSelect, + buildNameModal, + buildLimitModal, + isTemporaryOwner, + updatePanel, + togglePrivacy, + trustUser, + blockUser, + clearUserOverride, + transferOwnership, + kickUser, + deleteTemporaryRoom, + createRoomInvite, +} from '../services/temporaryVoicePanelService.js'; +import { logger } from '../utils/logger.js'; + +const PREFIX = 'tvp:'; + +export default { + name: Events.InteractionCreate, + once: false, + async execute(interaction, client) { + try { + const id = interaction.customId; + if (!id || !id.startsWith(PREFIX)) return; + + const parts = id.split(':'); + const action = parts[1]; + const channelId = action === 'select' || action === 'modal' ? parts[3] : parts[2]; + if (!channelId || !interaction.guild) return; + + const channel = interaction.guild.channels.cache.get(channelId) + || await interaction.guild.channels.fetch(channelId).catch(() => null); + if (!channel || !channel.isVoiceBased?.()) { + await safeReply(interaction, '❌ This temporary room no longer exists.'); + return; + } + + const info = await getTemporaryChannelInfo(client, interaction.guild.id, channelId); + if (!info) { + await safeReply(interaction, '❌ This is no longer an active temporary room.'); + return; + } + + if (!await isTemporaryOwner(interaction, client, channelId)) { + await safeReply(interaction, '❌ Only the current owner of this room can use the control panel.'); + return; + } + + if (interaction.isButton()) { + if (action === 'name') { + await interaction.showModal(buildNameModal(channelId, channel.name)); + return; + } + + if (action === 'limit') { + await interaction.showModal(buildLimitModal(channelId, channel.userLimit || 0)); + return; + } + + if (action === 'privacy') { + const isPublic = await togglePrivacy(client, channel); + await updatePanel(client, channel); + await safeReply(interaction, isPublic ? '🔓 Room is now public.' : '🔒 Room is now private.'); + return; + } + + if (action === 'invite') { + const invite = await createRoomInvite(channel); + await safeReply(interaction, `🔗 **Room invite:** ${invite.url}`); + return; + } + + const selectActions = { + trust: 'Select a member to trust.', + untrust: 'Select a member to untrust.', + kick: 'Select a member to kick from the room.', + block: 'Select a member to block.', + unblock: 'Select a member to unblock.', + transfer: 'Select the new room owner.', + }; + + if (selectActions[action]) { + await interaction.reply({ + content: `🎛️ ${selectActions[action]}`, + components: [buildUserSelect(action, channelId, selectActions[action])], + flags: MessageFlags.Ephemeral, + }); + return; + } + + if (action === 'delete') { + await interaction.reply({ content: '🗑️ Deleting the temporary room...', flags: MessageFlags.Ephemeral }); + await deleteTemporaryRoom(client, channel); + return; + } + } + + if (interaction.isUserSelectMenu() && action === 'select') { + const selectedAction = parts[2]; + const selectedId = interaction.values[0]; + const selectedMember = await interaction.guild.members.fetch(selectedId).catch(() => null); + if (!selectedMember) { + await safeReply(interaction, '❌ Member not found.'); + return; + } + + if (selectedAction === 'trust') { + await trustUser(channel, selectedId); + await safeReply(interaction, `✅ ${selectedMember} is now trusted in this room.`); + } else if (selectedAction === 'untrust') { + await clearUserOverride(channel, selectedId); + await safeReply(interaction, `✅ ${selectedMember} is no longer trusted.`); + } else if (selectedAction === 'block') { + await blockUser(channel, selectedId); + if (selectedMember.voice.channelId === channelId) { + await selectedMember.voice.disconnect('Blocked by temporary room owner').catch(() => null); + } + await safeReply(interaction, `🚫 ${selectedMember} is blocked from this room.`); + } else if (selectedAction === 'unblock') { + await clearUserOverride(channel, selectedId); + await safeReply(interaction, `✅ ${selectedMember} is unblocked.`); + } else if (selectedAction === 'kick') { + const removed = await kickUser(channel, selectedId); + await safeReply(interaction, removed ? `👢 ${selectedMember} was removed from the room.` : '❌ That member is not currently in this room.'); + } else if (selectedAction === 'transfer') { + if (selectedId === interaction.user.id) { + await safeReply(interaction, '❌ You already own this room.'); + return; + } + await transferOwnership(client, channel, selectedId); + await updatePanel(client, channel); + await safeReply(interaction, `👑 Ownership transferred to ${selectedMember}.`); + } + return; + } + + if (interaction.isModalSubmit() && action === 'modal') { + const modalType = parts[2]; + + if (modalType === 'name') { + const value = interaction.fields.getTextInputValue('name').trim(); + if (!value || value.length > 100) { + await safeReply(interaction, '❌ The room name must be between 1 and 100 characters.'); + return; + } + await channel.setName(value); + await updatePanel(client, channel); + await safeReply(interaction, '✅ Room name updated.'); + return; + } + + if (modalType === 'limit') { + const raw = interaction.fields.getTextInputValue('limit').trim(); + const limit = Number(raw); + if (!Number.isInteger(limit) || limit < 0 || limit > 99) { + await safeReply(interaction, '❌ Enter a number from **0** to **99**. 0 means unlimited.'); + return; + } + await channel.setUserLimit(limit); + await updatePanel(client, channel); + await safeReply(interaction, `✅ User limit set to **${limit === 0 ? 'Unlimited' : limit}**.`); + } + } + } catch (error) { + logger.error(`Temporary voice panel interaction error: ${error.message}`); + await safeReply(interaction, '❌ Something went wrong while updating the room.'); + } + } +}; + +async function safeReply(interaction, content) { + if (interaction.replied || interaction.deferred) { + return interaction.followUp({ content, flags: MessageFlags.Ephemeral }).catch(() => null); + } + return interaction.reply({ content, flags: MessageFlags.Ephemeral }).catch(() => null); +} diff --git a/src/handlers/loaders/commandLoader.js b/src/handlers/loaders/commandLoader.js index ecb7ad96d4..d9bd6084b8 100644 --- a/src/handlers/loaders/commandLoader.js +++ b/src/handlers/loaders/commandLoader.js @@ -7,20 +7,18 @@ import botConfig from '../../config/bot.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -const MAX_COMMANDS = 100; -const COMMAND_COUNT_WARN_THRESHOLD = 90; function getSubcommandInfo(commandData) { const subcommands = []; - + if (commandData.options) { for (const option of commandData.options) { -if (option.type === 1) { + if (option.type === 1) { subcommands.push(option.name); -} else if (option.type === 2) { + } else if (option.type === 2) { if (option.options) { for (const subOption of option.options) { -if (subOption.type === 1) { + if (subOption.type === 1) { subcommands.push(`${option.name}/${subOption.name}`); } } @@ -28,95 +26,92 @@ if (subOption.type === 1) { } } } - + return subcommands; } async function getAllFiles(directory, fileList = []) { const files = await fs.readdir(directory, { withFileTypes: true }); - + for (const file of files) { const filePath = path.join(directory, file.name); - + if (file.isDirectory()) { if (file.name === 'modules') { continue; } + await getAllFiles(filePath, fileList); } else if (file.name.endsWith('.js')) { fileList.push(filePath); } } - + return fileList; } export async function loadCommands(client) { client.commands = new Collection(); + const commandsPath = path.join(__dirname, '../../commands'); const commandFiles = await getAllFiles(commandsPath); - + logger.info(`Found ${commandFiles.length} command files to load`); - + const uniqueCommandNames = new Set(); - + for (const filePath of commandFiles) { try { const normalizedPath = filePath.replace(/\\/g, '/'); - - const commandName = path.basename(filePath, '.js'); - const commandDir = path.dirname(filePath); - const category = path.basename(commandDir); - + const commandModule = await import(`file://${filePath}`); const command = commandModule.default || commandModule; - + if (!command.data || !command.execute) { - logger.warn(`Command at ${filePath} is missing required "data" or "execute" property.`); + logger.warn( + `Command at ${filePath} is missing required "data" or "execute" property.`, + ); continue; } - + + const commandName = command.data.name; + const commandDir = path.dirname(filePath); + const category = path.basename(commandDir); + command.category = category; command.filePath = normalizedPath; - - const primaryCommandName = command.data.name; - - if (!uniqueCommandNames.has(primaryCommandName)) { - uniqueCommandNames.add(primaryCommandName); - - client.commands.set(primaryCommandName, command); + + if (!uniqueCommandNames.has(commandName)) { + uniqueCommandNames.add(commandName); + client.commands.set(commandName, command); } - + const subcommands = getSubcommandInfo(command.data.toJSON()); - - logger.info(`Loaded command: ${primaryCommandName} from ${normalizedPath} (category: ${category})`); - + + logger.info( + `Loaded command: ${commandName} from ${normalizedPath} (category: ${category})`, + ); + if (subcommands.length > 0) { - logger.info(` - Subcommands: ${subcommands.join(', ')}`); + logger.info( + ` - Subcommands: ${subcommands.join(', ')}`, + ); } - } catch (error) { logger.error(`Error loading command from ${filePath}:`, error); } } - - const commandsWithSubcommands = Array.from(client.commands.values()).filter(cmd => { - const subcommands = getSubcommandInfo(cmd.data.toJSON()); - return subcommands.length > 0; - }); - - const totalSubcommands = commandsWithSubcommands.reduce((total, cmd) => { - return total + getSubcommandInfo(cmd.data.toJSON()).length; - }, 0); - + const uniqueCommands = new Set(); + for (const [name, command] of client.commands.entries()) { if (command.data && command.data.name) { uniqueCommands.add(command.data.name); } } - + logger.info(`Loaded ${uniqueCommands.size} commands`); + return client.commands; } @@ -132,7 +127,6 @@ function collectCommandPayloads(client) { } const commandName = command.data.name; - logger.debug(`Processing command for registration: ${commandName}`); if (registeredNames.has(commandName)) { logger.debug(`Skipping duplicate command: ${commandName}`); @@ -140,8 +134,11 @@ function collectCommandPayloads(client) { } registeredNames.add(commandName); + const commandJson = command.data.toJSON(); + commands.push(commandJson); + totalSubcommands += getSubcommandInfo(commandJson).length; if (process.env.NODE_ENV !== 'production') { @@ -149,7 +146,10 @@ function collectCommandPayloads(client) { } } - return { commands, totalSubcommands }; + return { + commands, + totalSubcommands, + }; } function validateCommands(commands) { @@ -157,10 +157,15 @@ function validateCommands(commands) { for (const cmd of commands) { if (cmd.name && cmd.name.length > 32) { - validationErrors.push(`Command ${cmd.name} has name longer than 32 chars: "${cmd.name}" (${cmd.name.length} chars)`); + validationErrors.push( + `Command ${cmd.name} has name longer than 32 chars`, + ); } + if (cmd.description && cmd.description.length > 110) { - validationErrors.push(`Command ${cmd.name} has description longer than 110 chars: "${cmd.description}" (${cmd.description.length} chars)`); + validationErrors.push( + `Command ${cmd.name} has description longer than 110 chars`, + ); } if (!cmd.options) { @@ -169,19 +174,33 @@ function validateCommands(commands) { for (const option of cmd.options) { if (option.name && option.name.length > 32) { - validationErrors.push(`Command ${cmd.name} option ${option.name} has name longer than 32 chars: "${option.name}" (${option.name.length} chars)`); + validationErrors.push( + `Command ${cmd.name} option ${option.name} has name longer than 32 chars`, + ); } + if (option.description && option.description.length > 110) { - validationErrors.push(`Command ${cmd.name} option ${option.name} has description longer than 110 chars: "${option.description}" (${option.description.length} chars)`); + validationErrors.push( + `Command ${cmd.name} option ${option.name} has description longer than 110 chars`, + ); } if (option.choices) { for (const choice of option.choices) { if (choice.name && choice.name.length > 110) { - validationErrors.push(`Command ${cmd.name} option ${option.name} choice ${choice.name} has name longer than 110 chars: "${choice.name}" (${choice.name.length} chars)`); + validationErrors.push( + `Command ${cmd.name} option ${option.name} choice ${choice.name} has name longer than 110 chars`, + ); } - if (choice.value && choice.value.length > 100) { - validationErrors.push(`Command ${cmd.name} option ${option.name} choice ${choice.name} has value longer than 100 chars: "${choice.value}" (${choice.value.length} chars)`); + + if ( + choice.value && + typeof choice.value === 'string' && + choice.value.length > 100 + ) { + validationErrors.push( + `Command ${cmd.name} option ${option.name} choice ${choice.name} has value longer than 100 chars`, + ); } } } @@ -192,10 +211,18 @@ function validateCommands(commands) { for (const subOption of option.options) { if (subOption.name && subOption.name.length > 32) { - validationErrors.push(`Command ${cmd.name} subcommand ${option.name} option ${subOption.name} has name longer than 32 chars: "${subOption.name}" (${subOption.name.length} chars)`); + validationErrors.push( + `Command ${cmd.name} subcommand ${option.name} option ${subOption.name} has name longer than 32 chars`, + ); } - if (subOption.description && subOption.description.length > 110) { - validationErrors.push(`Command ${cmd.name} subcommand ${option.name} option ${subOption.name} has description longer than 110 chars: "${subOption.description}" (${subOption.description.length} chars)`); + + if ( + subOption.description && + subOption.description.length > 110 + ) { + validationErrors.push( + `Command ${cmd.name} subcommand ${option.name} option ${subOption.name} has description longer than 110 chars`, + ); } if (!subOption.choices) { @@ -204,10 +231,19 @@ function validateCommands(commands) { for (const choice of subOption.choices) { if (choice.name && choice.name.length > 110) { - validationErrors.push(`Command ${cmd.name} subcommand ${option.name} option ${subOption.name} choice ${choice.name} has name longer than 110 chars: "${choice.name}" (${choice.name.length} chars)`); + validationErrors.push( + `Command ${cmd.name} subcommand ${option.name} option ${subOption.name} choice ${choice.name} has name longer than 110 chars`, + ); } - if (choice.value && choice.value.length > 100) { - validationErrors.push(`Command ${cmd.name} subcommand ${option.name} option ${subOption.name} choice ${choice.name} has value longer than 100 chars: "${choice.value}" (${choice.value.length} chars)`); + + if ( + choice.value && + typeof choice.value === 'string' && + choice.value.length > 100 + ) { + validationErrors.push( + `Command ${cmd.name} subcommand ${option.name} option ${subOption.name} choice ${choice.name} has value longer than 100 chars`, + ); } } } @@ -216,59 +252,109 @@ function validateCommands(commands) { if (validationErrors.length > 0) { logger.error('Command validation failed. Errors:'); - validationErrors.forEach((error) => logger.error(` - ${error}`)); - throw new Error(`Command validation failed with ${validationErrors.length} errors`); - } -} -function prepareCommandsForRegistration(commands) { - if (commands.length >= COMMAND_COUNT_WARN_THRESHOLD) { - logger.warn(`Command count (${commands.length}) is near Discord's ${MAX_COMMANDS} global command limit`); - } + validationErrors.forEach((error) => { + logger.error(` - ${error}`); + }); - if (commands.length <= MAX_COMMANDS) { - return commands; + throw new Error( + `Command validation failed with ${validationErrors.length} errors`, + ); } - - logger.warn(`Command count (${commands.length}) exceeds Discord limit (${MAX_COMMANDS}), truncating...`); - const truncated = commands.slice(0, MAX_COMMANDS); - logger.info(`Truncated to ${truncated.length} commands for registration`); - return truncated; } -async function registerGlobalCommands(client, clientId, commands, totalSubcommands) { +async function registerGuildCommands( + client, + clientId, + guildId, + commands, + totalSubcommands, +) { if (!clientId) { - throw new Error('CLIENT_ID is required for slash command registration'); + throw new Error( + 'CLIENT_ID is required for slash command registration', + ); + } + + if (!guildId) { + throw new Error( + 'GUILD_ID is required for guild slash command registration', + ); } if (!client.rest) { - throw new Error('Discord REST client is not available for slash command registration'); + throw new Error( + 'Discord REST client is not available for slash command registration', + ); } - logger.info(`Preparing to register ${totalSubcommands + commands.length} commands globally`); + logger.info( + `Preparing to register ${commands.length} commands for guild ${guildId}`, + ); + + logger.info( + `Total subcommands: ${totalSubcommands}`, + ); + logger.info('Validating commands before registration...'); + validateCommands(commands); - logger.info('Command validation passed'); - const commandsToRegister = prepareCommandsForRegistration(commands); + logger.info('Command validation passed'); if (botConfig.commands?.deleteCommands) { - logger.info('Clearing existing global commands before registration...'); - await client.rest.put(`/applications/${clientId}/commands`, { body: [] }); + logger.info( + `Clearing existing guild commands for ${guildId}...`, + ); + + await client.rest.put( + `/applications/${clientId}/guilds/${guildId}/commands`, + { + body: [], + }, + ); + + logger.info('Existing guild commands cleared'); } - logger.info(`Registering ${commandsToRegister.length} global commands...`); - await client.rest.put(`/applications/${clientId}/commands`, { body: commandsToRegister }); - logger.info(`Successfully registered ${commandsToRegister.length} global commands`); - logger.info('Global commands may take up to an hour to appear in all servers on first deploy'); + logger.info( + `Registering ${commands.length} guild commands...`, + ); + + await client.rest.put( + `/applications/${clientId}/guilds/${guildId}/commands`, + { + body: commands, + }, + ); + + logger.info( + `Successfully registered ${commands.length} guild commands`, + ); } export async function registerCommands(client, options = {}) { - const { clientId = null } = options; + const clientId = + options.clientId || + process.env.CLIENT_ID; + + const guildId = + options.guildId || + process.env.GUILD_ID; try { - const { commands, totalSubcommands } = collectCommandPayloads(client); - await registerGlobalCommands(client, clientId, commands, totalSubcommands); + const { + commands, + totalSubcommands, + } = collectCommandPayloads(client); + + await registerGuildCommands( + client, + clientId, + guildId, + commands, + totalSubcommands, + ); } catch (error) { logger.error('Error registering commands:', error); throw error; @@ -277,24 +363,48 @@ export async function registerCommands(client, options = {}) { export async function reloadCommand(client, commandName) { const command = client.commands.get(commandName); - + if (!command) { - return { success: false, message: `Command "${commandName}" not found` }; + return { + success: false, + message: `Command "${commandName}" not found`, + }; } - + try { const commandPath = path.resolve(command.filePath); const moduleUrl = pathToFileURL(commandPath); - moduleUrl.searchParams.set('t', Date.now().toString()); - - const newCommand = (await import(moduleUrl.href)).default; - - client.commands.set(commandName, newCommand); - - logger.info(`Reloaded command: ${commandName}`); - return { success: true, message: `Successfully reloaded command "${commandName}"` }; + + moduleUrl.searchParams.set( + 't', + Date.now().toString(), + ); + + const newCommand = + (await import(moduleUrl.href)).default; + + client.commands.set( + commandName, + newCommand, + ); + + logger.info( + `Reloaded command: ${commandName}`, + ); + + return { + success: true, + message: `Successfully reloaded command "${commandName}"`, + }; } catch (error) { - logger.error(`Error reloading command "${commandName}":`, error); - return { success: false, message: `Error reloading command: ${error.message}` }; + logger.error( + `Error reloading command "${commandName}":`, + error, + ); + + return { + success: false, + message: `Error reloading command: ${error.message}`, + }; } -} \ No newline at end of file +} diff --git a/src/handlers/securityAdvancedHandlers.js b/src/handlers/securityAdvancedHandlers.js new file mode 100644 index 0000000000..d8872b97b4 --- /dev/null +++ b/src/handlers/securityAdvancedHandlers.js @@ -0,0 +1,199 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + EmbedBuilder, + ModalBuilder, + TextInputBuilder, + TextInputStyle, + MessageFlags, +} from 'discord.js'; +import { + getSecurityConfig, + updateSecurityConfig, + getStrikes, + clearStrikes, + sendSecurityLog, +} from '../services/security/securityService.js'; +import { WarningService } from '../services/moderation/warningService.js'; + +const RULES = { + spam: { label: '💬 Spam', color: 0xed4245 }, + duplicate: { label: '🔁 Duplicate', color: 0xf47b67 }, + mentions: { label: '📢 Mentions', color: 0xfee75c }, + invites: { label: '🔗 Invites', color: 0x5865f2 }, + links: { label: '🌐 Links', color: 0x5865f2 }, + caps: { label: '🔠 Caps', color: 0x57f287 }, + badWords: { label: '🚫 Bad Words', color: 0xed4245 }, +}; +const ACTIONS = ['delete', 'warn', 'timeout', 'kick', 'ban']; + +function allowed(i) { return i.customId.split(':').at(-1) === i.user.id; } +function reject(i) { return i.reply({ content: 'This security dashboard belongs to another moderator.', flags: MessageFlags.Ephemeral }); } +function btn(id, label, style = ButtonStyle.Secondary) { return new ButtonBuilder().setCustomId(id).setLabel(label).setStyle(style); } +function next(value, values = ACTIONS) { const index = values.indexOf(value); return values[(index + 1) % values.length]; } +function clamp(value, min, max) { return Math.min(max, Math.max(min, Number(value) || min)); } + +function ruleSummary(config, key) { + const r = config.autoMod[key] || {}; + const common = `**Status:** ${r.enabled ? '🟢 ACTIVE' : '🔴 OFF'}\n**Punishment:** \`${r.punishment || 'delete'}\``; + if (key === 'spam') return `${common}\n**Limit:** ${r.maxMessages ?? 6} messages\n**Window:** ${Math.round((r.windowMs || 5000) / 1000)} seconds`; + if (key === 'duplicate') return `${common}\n**Repeats:** ${r.maxRepeats ?? 3}\n**Window:** ${Math.round((r.windowMs || 10000) / 1000)} seconds`; + if (key === 'mentions') return `${common}\n**Maximum mentions:** ${r.max ?? 6}`; + if (key === 'caps') return `${common}\n**Uppercase ratio:** ${Math.round((r.ratio ?? 0.8) * 100)}%\n**Minimum length:** ${r.minLength ?? 12}`; + if (key === 'badWords') return `${common}\n**Blocked words:** ${r.words?.length || 0}`; + return common; +} + +function ruleEmbed(guild, config, key) { + const meta = RULES[key]; + return new EmbedBuilder() + .setAuthor({ name: 'Infinity Security Center', iconURL: guild.iconURL({ size: 128 }) || undefined }) + .setTitle(`${meta.label} • AutoMod`) + .setDescription(`Configure **${meta.label.replace(/^\S+\s/, '')}** without leaving this dashboard.\n\n${ruleSummary(config, key)}\n\n**Escalation:** violations add a Strike, then the configured Strike escalation can override the rule punishment.`) + .setColor(meta.color) + .setFooter({ text: 'Infinity System • AutoMod • Changes save automatically' }) + .setTimestamp(); +} + +function ruleControls(userId, config, key) { + const r = config.autoMod[key]; + const id = name => `${name}:${key}:${userId}`; + const rows = [ + new ActionRowBuilder().addComponents( + btn(id('security_automod_rule_back'), '← AutoMod'), + btn(id('security_automod_rule_toggle'), r.enabled ? '🟢 Disable' : '🔴 Enable', r.enabled ? ButtonStyle.Success : ButtonStyle.Danger), + btn(id('security_automod_rule_punishment'), `⚖️ ${r.punishment || 'delete'}`, ButtonStyle.Primary), + ), + ]; + + if (key === 'spam') rows.push(new ActionRowBuilder().addComponents( + btn(id('security_automod_rule_down'), '− Messages'), btn(id('security_automod_rule_up'), '+ Messages'), + btn(id('security_automod_rule_window'), `Window ${Math.round((r.windowMs || 5000) / 1000)}s`), + )); + else if (key === 'duplicate') rows.push(new ActionRowBuilder().addComponents( + btn(id('security_automod_rule_down'), '− Repeats'), btn(id('security_automod_rule_up'), '+ Repeats'), + btn(id('security_automod_rule_window'), `Window ${Math.round((r.windowMs || 10000) / 1000)}s`), + )); + else if (key === 'mentions') rows.push(new ActionRowBuilder().addComponents( + btn(id('security_automod_rule_down'), '− Mentions'), btn(id('security_automod_rule_up'), '+ Mentions'), + )); + else if (key === 'caps') rows.push(new ActionRowBuilder().addComponents( + btn(id('security_automod_rule_down'), '− Ratio'), btn(id('security_automod_rule_up'), '+ Ratio'), btn(id('security_automod_rule_min'), `Min length ${r.minLength ?? 12}`), + )); + else if (key === 'badWords') rows.push(new ActionRowBuilder().addComponents( + btn(id('security_automod_rule_words'), '✏️ Manage Words', ButtonStyle.Primary), + )); + + return rows; +} + +function autoModEmbed(guild, config) { + const lines = Object.entries(RULES).map(([key, meta]) => { + const r = config.autoMod[key] || {}; + return `${meta.label} — ${r.enabled ? '🟢' : '🔴'} • punishment: **${r.punishment || 'delete'}**`; + }); + return new EmbedBuilder() + .setAuthor({ name: 'Infinity Security Center', iconURL: guild.iconURL({ size: 128 }) || undefined }) + .setTitle('🤖 AutoMod Rules') + .setDescription(`**AutoMod:** ${config.autoMod.enabled ? '🟢 ACTIVE' : '🔴 OFF'}\n\nChoose a rule to configure it. Each rule has its own enable switch, thresholds and punishment.\n\n${lines.join('\n')}`) + .setColor(0x5865f2) + .setFooter({ text: 'Infinity System • AutoMod • Select a rule to edit it' }) + .setTimestamp(); +} + +function autoModControls(userId, config) { + const keys = Object.keys(RULES); + const rows = [new ActionRowBuilder().addComponents( + btn(`security_back:${userId}`, '← Back'), + btn(`security_automod_toggle:${userId}`, config.autoMod.enabled ? '🟢 Disable AutoMod' : '🔴 Enable AutoMod', config.autoMod.enabled ? ButtonStyle.Success : ButtonStyle.Danger), + )]; + for (let i = 0; i < keys.length; i += 3) { + rows.push(new ActionRowBuilder().addComponents(...keys.slice(i, i + 3).map(key => btn(`security_automod_rule:${key}:${userId}`, RULES[key].label, config.autoMod[key]?.enabled ? ButtonStyle.Primary : ButtonStyle.Secondary)))); + } + return rows.slice(0, 5); +} + +async function renderAutoMod(i, client) { + const config = await getSecurityConfig(client, i.guildId); + return i.update({ embeds: [autoModEmbed(i.guild, config)], components: autoModControls(i.user.id, config) }); +} + +async function renderRule(i, client, key) { + const config = await getSecurityConfig(client, i.guildId); + return i.update({ embeds: [ruleEmbed(i.guild, config, key)], components: ruleControls(i.user.id, config, key) }); +} + +async function getCombinedEntries(client, guild) { + const config = await getSecurityConfig(client, guild.id); + const warnings = await WarningService.getGuildWarnings(client, guild.id, { limit: 1000 }).catch(() => []); + const warningMap = new Map(); + for (const warning of warnings) warningMap.set(warning.userId, (warningMap.get(warning.userId) || 0) + 1); + const members = await guild.members.fetch().catch(() => guild.members.cache); + const entries = []; + for (const member of members.values()) { + if (member.user?.bot) continue; + const strike = await getStrikes(client, guild.id, member.id).catch(() => ({ count: 0, updatedAt: 0 })); + const expired = config.strikeDecayMs && strike.updatedAt && Date.now() - strike.updatedAt > Number(config.strikeDecayMs); + const strikes = expired ? 0 : Number(strike.count || 0); + const warningCount = warningMap.get(member.id) || 0; + if (!strikes && !warningCount) continue; + entries.push({ userId: member.id, strikes, warnings: warningCount, updatedAt: strike.updatedAt || 0 }); + } + return entries.sort((a, b) => (b.strikes + b.warnings) - (a.strikes + a.warnings) || b.strikes - a.strikes).slice(0, 5); +} + +function combinedEmbed(guild, entries) { + const text = entries.length ? entries.map((e, i) => `${i + 1}. <@${e.userId}> — **${e.strikes}** Strikes • **${e.warnings}** Warnings`).join('\n') : '✅ No active Strikes or Warnings.'; + return new EmbedBuilder().setAuthor({ name: 'Infinity Security Center', iconURL: guild.iconURL({ size: 128 }) || undefined }).setTitle('🏆 Strikes & Warnings').setDescription(`Top members by active security history.\n\n${text}\n\nSelect a member below to view their history and manage it.`).setColor(0xfee75c).setFooter({ text: 'Resetting/clearing records does not delete Security Logs.' }).setTimestamp(); +} + +function combinedControls(userId, entries) { + const rows = []; + if (entries.length) rows.push(new ActionRowBuilder().addComponents(...entries.map((e, i) => btn(`security_member_manage:${e.userId}:${userId}`, `Manage #${i + 1}`, ButtonStyle.Primary)))); + rows.push(new ActionRowBuilder().addComponents(btn(`security_back:${userId}`, '← Back'), btn(`security_strikes_refresh:${userId}`, '🔄 Refresh', ButtonStyle.Success))); + return rows; +} + +async function combinedBoard(i, client) { + const entries = await getCombinedEntries(client, i.guild); + return i.update({ embeds: [combinedEmbed(i.guild, entries)], components: combinedControls(i.user.id, entries) }); +} + +async function memberPage(i, client, userId) { + const member = await i.guild.members.fetch(userId).catch(() => null); + const strike = await getStrikes(client, i.guildId, userId).catch(() => ({ count: 0, updatedAt: 0, lastReason: '' })); + const warnings = await WarningService.getWarnings(i.guildId, userId).catch(() => []); + const warningText = warnings.length ? warnings.slice(-5).reverse().map(w => `• ${new Date(w.timestamp || Date.now()).toLocaleString()} — ${String(w.reason || 'No reason').slice(0, 120)}`).join('\n') : 'No active warnings.'; + const embed = new EmbedBuilder().setTitle(`👤 Security History • ${member?.user?.tag || userId}`).setDescription(`**Strikes:** ${strike.count || 0}\n**Warnings:** ${warnings.length}\n**Last Strike Reason:** ${strike.lastReason || '—'}\n\n**Recent Warnings**\n${warningText}`).setColor(0xfee75c).setFooter({ text: 'All changes happen inside this message.' }).setTimestamp(); + return i.update({ embeds: [embed], components: [new ActionRowBuilder().addComponents( + btn(`security_member_reset:${userId}:${i.user.id}`, '🧹 Reset Strikes', ButtonStyle.Danger), + btn(`security_member_clearwarnings:${userId}:${i.user.id}`, '🗑️ Clear Warnings', ButtonStyle.Danger), + btn(`security_member_back:${i.user.id}`, '← Back'), + )] }); +} + +const handlers = []; +handlers.push({ name: 'security_panel_automod', execute: async (i, c) => allowed(i) ? renderAutoMod(i, c) : reject(i) }); +handlers.push({ name: 'security_automod_toggle', execute: async (i, c) => { if (!allowed(i)) return reject(i); const x = await getSecurityConfig(c, i.guildId); await updateSecurityConfig(c, i.guildId, { autoMod: { enabled: !x.autoMod.enabled } }); return renderAutoMod(i, c); } }); +handlers.push({ name: 'security_automod_rule', execute: async (i, c) => { if (!allowed(i)) return reject(i); const parts = i.customId.split(':'); return renderRule(i, c, parts.at(-2)); } }); +handlers.push({ name: 'security_automod_rule_back', execute: async (i, c) => { if (!allowed(i)) return reject(i); return renderAutoMod(i, c); } }); +handlers.push({ name: 'security_automod_rule_toggle', execute: async (i, c) => { if (!allowed(i)) return reject(i); const parts = i.customId.split(':'); const key = parts.at(-2); const x = await getSecurityConfig(c, i.guildId); await updateSecurityConfig(c, i.guildId, { autoMod: { [key]: { enabled: !x.autoMod[key].enabled } } }); return renderRule(i, c, key); } }); +handlers.push({ name: 'security_automod_rule_punishment', execute: async (i, c) => { if (!allowed(i)) return reject(i); const parts = i.customId.split(':'); const key = parts.at(-2); const x = await getSecurityConfig(c, i.guildId); await updateSecurityConfig(c, i.guildId, { autoMod: { [key]: { punishment: next(x.autoMod[key].punishment || 'delete') } } }); return renderRule(i, c, key); } }); +handlers.push({ name: 'security_automod_rule_down', execute: async (i, c) => { if (!allowed(i)) return reject(i); const parts = i.customId.split(':'); const key = parts.at(-2); const x = await getSecurityConfig(c, i.guildId); const r = x.autoMod[key]; const field = key === 'spam' ? 'maxMessages' : key === 'duplicate' ? 'maxRepeats' : key === 'mentions' ? 'max' : 'ratio'; const delta = field === 'ratio' ? -0.05 : -1; const min = field === 'ratio' ? 0.5 : key === 'mentions' ? 1 : 2; await updateSecurityConfig(c, i.guildId, { autoMod: { [key]: { [field]: clamp((r[field] ?? min) + delta, min, field === 'ratio' ? 1 : 30) } } }); return renderRule(i, c, key); } }); +handlers.push({ name: 'security_automod_rule_up', execute: async (i, c) => { if (!allowed(i)) return reject(i); const parts = i.customId.split(':'); const key = parts.at(-2); const x = await getSecurityConfig(c, i.guildId); const r = x.autoMod[key]; const field = key === 'spam' ? 'maxMessages' : key === 'duplicate' ? 'maxRepeats' : key === 'mentions' ? 'max' : 'ratio'; const delta = field === 'ratio' ? 0.05 : 1; const max = field === 'ratio' ? 1 : 30; await updateSecurityConfig(c, i.guildId, { autoMod: { [key]: { [field]: clamp((r[field] ?? 2) + delta, field === 'ratio' ? 0.5 : 1, max) } } }); return renderRule(i, c, key); } }); +handlers.push({ name: 'security_automod_rule_min', execute: async (i, c) => { if (!allowed(i)) return reject(i); const parts = i.customId.split(':'); const key = parts.at(-2); const x = await getSecurityConfig(c, i.guildId); const value = ((x.autoMod[key].minLength ?? 12) + 2); await updateSecurityConfig(c, i.guildId, { autoMod: { [key]: { minLength: value > 40 ? 8 : value } } }); return renderRule(i, c, key); } }); +handlers.push({ name: 'security_automod_rule_window', execute: async (i, c) => { if (!allowed(i)) return reject(i); const parts = i.customId.split(':'); const key = parts.at(-2); const x = await getSecurityConfig(c, i.guildId); const current = x.autoMod[key].windowMs || 5000; const values = [3000, 5000, 10000, 15000, 30000, 60000]; await updateSecurityConfig(c, i.guildId, { autoMod: { [key]: { windowMs: next(current, values) } } }); return renderRule(i, c, key); } }); +handlers.push({ name: 'security_automod_rule_words', execute: async (i, c) => { if (!allowed(i)) return reject(i); const x = await getSecurityConfig(c, i.guildId); const input = new TextInputBuilder().setCustomId('words').setLabel('Blocked words separated by spaces').setStyle(TextInputStyle.Paragraph).setRequired(false).setValue((x.autoMod.badWords.words || []).join(' ').slice(0, 4000)); return i.showModal(new ModalBuilder().setCustomId(`security_automod_rule_words_modal:${i.user.id}`).setTitle('AutoMod • Bad Words').addComponents(new ActionRowBuilder().addComponents(input))); } }); +handlers.push({ name: 'security_panel_strikes', execute: async (i, c) => allowed(i) ? combinedBoard(i, c) : reject(i) }); +handlers.push({ name: 'security_strikes_refresh', execute: async (i, c) => allowed(i) ? combinedBoard(i, c) : reject(i) }); +handlers.push({ name: 'security_member_manage', execute: async (i, c) => { if (!allowed(i)) return reject(i); return memberPage(i, c, i.customId.split(':').at(-2)); } }); +handlers.push({ name: 'security_member_back', execute: async (i, c) => allowed(i) ? combinedBoard(i, c) : reject(i) }); +handlers.push({ name: 'security_member_reset', execute: async (i, c) => { if (!allowed(i)) return reject(i); const userId = i.customId.split(':').at(-2); await clearStrikes(c, i.guildId, userId); await sendSecurityLog(c, i.guild, { title: 'Security Strikes Reset', description: `Strikes reset for <@${userId}> by <@${i.user.id}>.`, color: 0x57f287 }); return memberPage(i, c, userId); } }); +handlers.push({ name: 'security_member_clearwarnings', execute: async (i, c) => { if (!allowed(i)) return reject(i); const userId = i.customId.split(':').at(-2); const result = await WarningService.clearWarnings(i.guildId, userId); await sendSecurityLog(c, i.guild, { title: 'Security Warnings Cleared', description: `Cleared ${result.count} warning(s) for <@${userId}> by <@${i.user.id}>.`, color: 0x57f287 }); return memberPage(i, c, userId); } }); + +export const securityAdvancedButtonHandlers = handlers; + +export const securityAdvancedModalHandlers = [ + { name: 'security_automod_rule_words_modal', execute: async (i, c) => { if (!allowed(i)) return reject(i); const words = String(i.fields.getTextInputValue('words') || '').split(/\s+/).map(x => x.trim()).filter(Boolean).slice(0, 100); await updateSecurityConfig(c, i.guildId, { autoMod: { badWords: { enabled: words.length > 0, words } } }); return renderRule(i, c, 'badWords'); } }, +]; diff --git a/src/handlers/securityAutoModDashboard.js b/src/handlers/securityAutoModDashboard.js new file mode 100644 index 0000000000..f20d66309b --- /dev/null +++ b/src/handlers/securityAutoModDashboard.js @@ -0,0 +1,161 @@ +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from 'discord.js'; +import { getSecurityConfig, updateSecurityConfig } from '../services/security/securityService.js'; + +const RULES = { + spam: { label: '💬 Spam', values: ['delete', 'warn', 'timeout', 'kick', 'ban'] }, + duplicate: { label: '🔁 Duplicate', values: ['delete', 'warn', 'timeout', 'kick', 'ban'] }, + mentions: { label: '📢 Mentions', values: ['delete', 'warn', 'timeout', 'kick', 'ban'] }, + invites: { label: '🔗 Invites', values: ['delete', 'warn', 'timeout', 'kick', 'ban'] }, + links: { label: '🌐 Links', values: ['delete', 'warn', 'timeout', 'kick', 'ban'] }, + caps: { label: '🔠 Caps', values: ['delete', 'warn', 'timeout', 'kick', 'ban'] }, + badWords: { label: '🚫 Bad Words', values: ['delete', 'warn', 'timeout', 'kick', 'ban'] }, +}; + +const ok = i => i.customId.split(':').at(-1) === i.user.id; +const deny = i => i.reply({ content: 'This security dashboard belongs to another moderator.', ephemeral: true }); +const B = (id, label, style = ButtonStyle.Secondary) => new ButtonBuilder().setCustomId(id).setLabel(label).setStyle(style); +const row = (...buttons) => new ActionRowBuilder().addComponents(buttons); +const cycle = (value, values) => { + const index = values.indexOf(value); + return values[(index < 0 ? -1 : index) + 1 >= values.length ? 0 : index + 1]; +}; + +function embed(title, description, guild) { + return new EmbedBuilder() + .setAuthor({ name: 'Infinity Security Center', iconURL: guild.iconURL({ size: 128 }) || undefined }) + .setTitle(title) + .setDescription(description) + .setColor(0x5865f2) + .setFooter({ text: 'Infinity System • Changes save automatically' }) + .setTimestamp(); +} + +async function autoModPage(i, client) { + const config = await getSecurityConfig(client, i.guildId); + const rows = [row(B(`security_back2:${i.user.id}`, '← Back'), B(`automod_global_toggle:${i.user.id}`, config.autoMod.enabled ? '🔴 Disable AutoMod' : '🟢 Enable AutoMod', config.autoMod.enabled ? ButtonStyle.Danger : ButtonStyle.Success))]; + const keys = Object.keys(RULES); + for (let n = 0; n < keys.length; n += 4) { + rows.push(row(...keys.slice(n, n + 4).map(key => { + const rule = config.autoMod[key]; + return B(`automod_rule:${key}:${i.user.id}`, `${RULES[key].label}: ${rule.enabled ? 'ON' : 'OFF'}`, rule.enabled ? ButtonStyle.Success : ButtonStyle.Secondary); + }))); + } + return i.update({ embeds: [embed('🤖 AutoMod', `**Status:** ${config.autoMod.enabled ? '🟢 ACTIVE' : '🔴 OFF'}\n\nChoose a rule to configure its detection settings and punishment.`, i.guild)], components: rows }); +} + +async function rulePage(i, client, key) { + const config = await getSecurityConfig(client, i.guildId); + const rule = config.autoMod[key]; + const meta = RULES[key]; + if (!rule || !meta) return autoModPage(i, client); + + const settings = []; + if (key === 'spam') settings.push(`**Limit:** ${rule.maxMessages} messages`, `**Window:** ${Math.round(rule.windowMs / 1000)} seconds`); + if (key === 'duplicate') settings.push(`**Repeats:** ${rule.maxRepeats}`, `**Window:** ${Math.round(rule.windowMs / 1000)} seconds`); + if (key === 'mentions') settings.push(`**Max mentions:** ${rule.max}`); + if (key === 'caps') settings.push(`**Caps ratio:** ${Math.round(rule.ratio * 100)}%`, `**Minimum length:** ${rule.minLength}`); + if (key === 'badWords') settings.push(`**Blocked words:** ${rule.words?.length || 0}`); + if (!settings.length) settings.push('This rule has no extra detection values.'); + + const rows = [ + row(B(`automod_back:${i.user.id}`, '← AutoMod'), B(`automod_toggle:${key}:${i.user.id}`, rule.enabled ? '🔴 Disable' : '🟢 Enable', rule.enabled ? ButtonStyle.Danger : ButtonStyle.Success), B(`automod_punishment:${key}:${i.user.id}`, `⚖️ ${rule.punishment}`, ButtonStyle.Primary)), + ]; + + if (key === 'spam' || key === 'duplicate') rows.push(row(B(`automod_limit:${key}:${i.user.id}`, key === 'spam' ? `Limit: ${rule.maxMessages}` : `Repeats: ${rule.maxRepeats}`, ButtonStyle.Secondary), B(`automod_window:${key}:${i.user.id}`, `Window: ${Math.round(rule.windowMs / 1000)}s`, ButtonStyle.Secondary))); + if (key === 'mentions') rows.push(row(B(`automod_limit:${key}:${i.user.id}`, `Max: ${rule.max}`, ButtonStyle.Secondary))); + if (key === 'caps') rows.push(row(B(`automod_limit:${key}:${i.user.id}`, `Ratio: ${Math.round(rule.ratio * 100)}%`, ButtonStyle.Secondary), B(`automod_min:${key}:${i.user.id}`, `Min: ${rule.minLength}`, ButtonStyle.Secondary))); + if (key === 'badWords') rows.push(row(B(`automod_words:${i.user.id}`, `Blocked words: ${rule.words?.length || 0}`, ButtonStyle.Secondary))); + + return i.update({ embeds: [embed(`${meta.label} Settings`, `**Status:** ${rule.enabled ? '🟢 ACTIVE' : '🔴 OFF'}\n**Punishment:** **${rule.punishment}**\n\n${settings.join('\n')}`, i.guild)], components: rows }); +} + +function parseKey(i) { return i.customId.split(':').at(-2); } + +export default [ + { + name: 'security_panel_automod', + execute: async (i, client) => ok(i) ? autoModPage(i, client) : deny(i), + }, + { + name: 'security_panel_automod2', + execute: async (i, client) => ok(i) ? autoModPage(i, client) : deny(i), + }, + { + name: 'automod_global_toggle', + execute: async (i, client) => { + if (!ok(i)) return deny(i); + const x = await getSecurityConfig(client, i.guildId); + await updateSecurityConfig(client, i.guildId, { autoMod: { enabled: !x.autoMod.enabled } }); + return autoModPage(i, client); + }, + }, + { + name: 'automod_rule', + execute: async (i, client) => ok(i) ? rulePage(i, client, parseKey(i)) : deny(i), + }, + { + name: 'automod_back', + execute: async (i, client) => ok(i) ? autoModPage(i, client) : deny(i), + }, + { + name: 'automod_toggle', + execute: async (i, client) => { + if (!ok(i)) return deny(i); + const key = parseKey(i); + const x = await getSecurityConfig(client, i.guildId); + await updateSecurityConfig(client, i.guildId, { autoMod: { [key]: { enabled: !x.autoMod[key].enabled } } }); + return rulePage(i, client, key); + }, + }, + { + name: 'automod_punishment', + execute: async (i, client) => { + if (!ok(i)) return deny(i); + const key = parseKey(i); + const x = await getSecurityConfig(client, i.guildId); + const values = RULES[key].values; + await updateSecurityConfig(client, i.guildId, { autoMod: { [key]: { punishment: cycle(x.autoMod[key].punishment, values) } } }); + return rulePage(i, client, key); + }, + }, + { + name: 'automod_limit', + execute: async (i, client) => { + if (!ok(i)) return deny(i); + const key = parseKey(i); + const x = await getSecurityConfig(client, i.guildId); + const rule = x.autoMod[key]; + const patch = key === 'spam' ? { maxMessages: rule.maxMessages >= 12 ? 3 : rule.maxMessages + 1 } : key === 'duplicate' ? { maxRepeats: rule.maxRepeats >= 8 ? 2 : rule.maxRepeats + 1 } : key === 'mentions' ? { max: rule.max >= 15 ? 3 : rule.max + 1 } : key === 'caps' ? { ratio: rule.ratio >= 0.95 ? 0.5 : Number((rule.ratio + 0.05).toFixed(2)) } : {}; + await updateSecurityConfig(client, i.guildId, { autoMod: { [key]: patch } }); + return rulePage(i, client, key); + }, + }, + { + name: 'automod_window', + execute: async (i, client) => { + if (!ok(i)) return deny(i); + const key = parseKey(i); + const x = await getSecurityConfig(client, i.guildId); + const current = Math.round(x.autoMod[key].windowMs / 1000); + const values = key === 'spam' ? [3, 5, 10, 15, 30] : [5, 10, 15, 30, 60]; + const next = cycle(current, values); + await updateSecurityConfig(client, i.guildId, { autoMod: { [key]: { windowMs: next * 1000 } } }); + return rulePage(i, client, key); + }, + }, + { + name: 'automod_min', + execute: async (i, client) => { + if (!ok(i)) return deny(i); + const key = parseKey(i); + const x = await getSecurityConfig(client, i.guildId); + const next = x.autoMod[key].minLength >= 30 ? 8 : x.autoMod[key].minLength + 2; + await updateSecurityConfig(client, i.guildId, { autoMod: { [key]: { minLength: next } } }); + return rulePage(i, client, key); + }, + }, + { + name: 'automod_words', + execute: async (i, client) => ok(i) ? rulePage(i, client, 'badWords') : deny(i), + }, +]; diff --git a/src/handlers/securityDashboardCore.js b/src/handlers/securityDashboardCore.js new file mode 100644 index 0000000000..e3d28f60ad --- /dev/null +++ b/src/handlers/securityDashboardCore.js @@ -0,0 +1,115 @@ +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from 'discord.js'; +import { getSecurityConfig, updateSecurityConfig, getStrikes, clearStrikes } from '../services/security/securityService.js'; +import { buildSecurityDashboard as buildOriginalSecurityDashboard, buildSecurityControls } from '../commands/Security/security.js'; + +const NUKE_ACTIONS = ['strip', 'kick', 'ban']; +const RAID_ACTIONS = ['timeout', 'kick', 'ban']; + +const ok = interaction => interaction.customId.split(':').at(-1) === interaction.user.id; +const deny = interaction => interaction.reply({ content: 'This security dashboard belongs to another moderator.', ephemeral: true }); +const button = (id, label, style = ButtonStyle.Secondary) => new ButtonBuilder().setCustomId(id).setLabel(label).setStyle(style); +const row = (...buttons) => new ActionRowBuilder().addComponents(buttons); +const cycle = (value, values) => values[(values.indexOf(value) + 1) % values.length]; + +function embed(title, description, guild, color = 0x5865f2) { + return new EmbedBuilder() + .setAuthor({ name: 'Infinity Security Center', iconURL: guild.iconURL({ size: 128 }) || undefined }) + .setTitle(title) + .setDescription(description) + .setColor(color) + .setFooter({ text: 'Infinity System • Changes save automatically' }) + .setTimestamp(); +} + +// IMPORTANT: this must be the exact same dashboard used by /security. +// Do not maintain a second dashboard layout here. +export async function buildSecurityDashboard(client, guild, userId) { + const config = await getSecurityConfig(client, guild.id); + return { + embeds: [buildOriginalSecurityDashboard(config, guild)], + components: buildSecurityControls(userId), + }; +} + +async function dashboard(interaction, client) { + return interaction.update(await buildSecurityDashboard(client, interaction.guild, interaction.user.id)); +} + +async function panel(interaction, client, type) { + const config = await getSecurityConfig(client, interaction.guildId); + let title = '🛡️ Security'; + let description = ''; + let color = 0x5865f2; + let components = [row(button(`security_back2:${interaction.user.id}`, '← Back'))]; + + if (type === 'nuke') { + title = '🛡️ Anti-Nuke'; color = 0xed4245; + description = `**Status:** ${config.antiNuke.enabled ? '🟢 ACTIVE' : '🔴 OFF'}\n**Window:** ${config.antiNuke.windowMs / 1000}s\n**Lockdown:** ${config.antiNuke.lockdown ? '🟢 ON' : '🔴 OFF'}\n**Default action:** ${config.antiNuke.action || 'strip'}`; + components = [row(button(`security_back2:${interaction.user.id}`, '← Back'), button(`nuke_toggle2:${interaction.user.id}`, config.antiNuke.enabled ? 'Disable' : 'Enable', config.antiNuke.enabled ? ButtonStyle.Success : ButtonStyle.Danger), button(`nuke_lock2:${interaction.user.id}`, `Lockdown: ${config.antiNuke.lockdown ? 'ON' : 'OFF'}`), button(`nuke_window2:${interaction.user.id}`, `Window: ${config.antiNuke.windowMs / 1000}s`)), row(button(`nuke_rules2:${interaction.user.id}`, '⚖️ Rule Punishments', ButtonStyle.Primary))]; + } else if (type === 'raid') { + title = '🚨 Anti-Raid'; color = 0xf47b67; + description = `**Status:** ${config.antiRaid.enabled ? '🟢 ACTIVE' : '🔴 OFF'}\n**Joins:** ${config.antiRaid.joins}\n**Window:** ${config.antiRaid.windowMs / 1000}s\n**Account age:** ${Math.round(config.antiRaid.minAccountAgeMs / 3600000)}h\n**Punishment:** ${config.antiRaid.punishment}\n**Lockdown:** ${config.antiRaid.lockdown ? 'ON' : 'OFF'}`; + components = [row(button(`security_back2:${interaction.user.id}`, '← Back'), button(`raid_toggle2:${interaction.user.id}`, config.antiRaid.enabled ? 'Disable' : 'Enable', config.antiRaid.enabled ? ButtonStyle.Success : ButtonStyle.Danger), button(`raid_punishment2:${interaction.user.id}`, `Punishment: ${config.antiRaid.punishment}`, ButtonStyle.Primary)), row(button(`raid_joins2:${interaction.user.id}`, `Joins: ${config.antiRaid.joins}`), button(`raid_window2:${interaction.user.id}`, `Window: ${config.antiRaid.windowMs / 1000}s`), button(`raid_age2:${interaction.user.id}`, `Age: ${Math.round(config.antiRaid.minAccountAgeMs / 3600000)}h`), button(`raid_lock2:${interaction.user.id}`, `Lockdown: ${config.antiRaid.lockdown ? 'ON' : 'OFF'}`))]; + } else if (type === 'punishments') { + title = '⚖️ Punishments'; color = 0xfee75c; + const escalation = (config.escalation || []).map(e => `Strike ${e.strike} → **${e.action}**`).join('\n') || 'No escalation rules configured.'; + description = `**Anti-Raid:** ${config.antiRaid.punishment}\n**Strike decay:** ${Math.round(config.strikeDecayMs / 3600000)}h\n\n**Escalation**\n${escalation}`; + components = [row(button(`security_back2:${interaction.user.id}`, '← Back'), button(`punishment_decay2:${interaction.user.id}`, '⏱️ Decay', ButtonStyle.Primary), button(`punishment_rules2:${interaction.user.id}`, '⚖️ Rule Punishments', ButtonStyle.Primary))]; + } else if (type === 'whitelist') { + title = '👤 Whitelist'; color = 0x57f287; + description = `**Users:** ${config.whitelist?.users?.length || 0}\n**Roles:** ${config.whitelist?.roles?.length || 0}\n**Bots:** ${config.whitelist?.bots?.length || 0}`; + components = [row(button(`security_back2:${interaction.user.id}`, '← Back'), button(`wl_users2:${interaction.user.id}`, '👤 Users', ButtonStyle.Primary), button(`wl_roles2:${interaction.user.id}`, '🎭 Roles', ButtonStyle.Primary), button(`wl_bots2:${interaction.user.id}`, '🤖 Bots', ButtonStyle.Primary))]; + } else if (type === 'logs') { + title = '📋 Security Logs'; + description = `**Log channel:** ${config.logChannelId ? `<#${config.logChannelId}>` : 'Not configured'}\n**Ignored channels:** ${config.ignoredChannels?.length || 0}`; + components = [row(button(`security_back2:${interaction.user.id}`, '← Back'), button(`logs_channel2:${interaction.user.id}`, '📋 Set Log Channel', ButtonStyle.Primary), button(`logs_ignored2:${interaction.user.id}`, '🚫 Ignored Channels'))]; + } else if (type === 'settings') { + title = '⚙️ Security Settings'; color = 0x57f287; + description = `**Global protection:** ${config.enabled ? '🟢 ON' : '🔴 OFF'}\n**Strike decay:** ${Math.round(config.strikeDecayMs / 3600000)}h\n**Ignored channels:** ${config.ignoredChannels?.length || 0}`; + components = [row(button(`security_back2:${interaction.user.id}`, '← Back'), button(`settings_toggle2:${interaction.user.id}`, config.enabled ? 'Disable Protection' : 'Enable Protection'), button(`settings_decay2:${interaction.user.id}`, '⏱️ Strike Decay', ButtonStyle.Primary))]; + } + + return interaction.update({ embeds: [embed(title, description, interaction.guild, color)], components }); +} + +async function strikes(interaction, client) { + const members = await interaction.guild.members.fetch().catch(() => interaction.guild.members.cache); + const entries = []; + for (const member of members.values()) { + if (member.user.bot) continue; + const strike = await getStrikes(client, interaction.guildId, member.id).catch(() => ({ count: 0 })); + if (strike.count) entries.push({ id: member.id, count: strike.count }); + } + entries.sort((a, b) => b.count - a.count); + const text = entries.slice(0, 10).map((entry, index) => `${index + 1}. <@${entry.id}> — **${entry.count}** strikes`).join('\n') || 'No active strikes.'; + return interaction.update({ embeds: [embed('🏆 Strikes & Warnings', text, interaction.guild, 0xfee75c)], components: [row(button(`security_back2:${interaction.user.id}`, '← Back'), button(`strikes_refresh2:${interaction.user.id}`, '🔄 Refresh', ButtonStyle.Success))] }); +} + +const panelHandlers = { + security_panel_nuke2: 'nuke', security_panel_raid2: 'raid', security_panel_punishments2: 'punishments', security_panel_whitelist2: 'whitelist', security_panel_logs2: 'logs', security_panel_settings2: 'settings', + security_panel_nuke: 'nuke', security_panel_raid: 'raid', security_panel_punishments: 'punishments', security_panel_whitelist: 'whitelist', security_panel_logs: 'logs', security_panel_settings: 'settings', +}; + +export const securityDashboardButtonHandlers = [ + ...Object.entries(panelHandlers).map(([name, type]) => ({ name, execute: async (interaction, client) => ok(interaction) ? panel(interaction, client, type) : deny(interaction) })), + { name: 'security_panel_strikes2', execute: async (interaction, client) => ok(interaction) ? strikes(interaction, client) : deny(interaction) }, + { name: 'security_panel_strikes', execute: async (interaction, client) => ok(interaction) ? strikes(interaction, client) : deny(interaction) }, + { name: 'security_back2', execute: async (interaction, client) => ok(interaction) ? dashboard(interaction, client) : deny(interaction) }, + { name: 'security_back', execute: async (interaction, client) => ok(interaction) ? dashboard(interaction, client) : deny(interaction) }, + { name: 'security_refresh', execute: async (interaction, client) => ok(interaction) ? dashboard(interaction, client) : deny(interaction) }, + { name: 'nuke_toggle2', execute: async (i, c) => { if (!ok(i)) return deny(i); const x = await getSecurityConfig(c, i.guildId); await updateSecurityConfig(c, i.guildId, { antiNuke: { enabled: !x.antiNuke.enabled } }); return panel(i, c, 'nuke'); } }, + { name: 'nuke_lock2', execute: async (i, c) => { if (!ok(i)) return deny(i); const x = await getSecurityConfig(c, i.guildId); await updateSecurityConfig(c, i.guildId, { antiNuke: { lockdown: !x.antiNuke.lockdown } }); return panel(i, c, 'nuke'); } }, + { name: 'nuke_window2', execute: async (i, c) => { if (!ok(i)) return deny(i); const x = await getSecurityConfig(c, i.guildId); await updateSecurityConfig(c, i.guildId, { antiNuke: { windowMs: cycle(x.antiNuke.windowMs, [5000, 10000, 15000, 30000, 60000]) } }); return panel(i, c, 'nuke'); } }, + { name: 'nuke_rules2', execute: async (i, c) => ok(i) ? i.update({ embeds: [embed('⚖️ Anti-Nuke Rule Punishments', 'Use the rule buttons from the security punishment dashboard.', i.guild, 0xed4245)], components: [row(button(`punishment_rules2:${i.user.id}`, '← Punishments'))] }) : deny(i) }, + { name: 'raid_toggle2', execute: async (i, c) => { if (!ok(i)) return deny(i); const x = await getSecurityConfig(c, i.guildId); await updateSecurityConfig(c, i.guildId, { antiRaid: { enabled: !x.antiRaid.enabled } }); return panel(i, c, 'raid'); } }, + { name: 'raid_punishment2', execute: async (i, c) => { if (!ok(i)) return deny(i); const x = await getSecurityConfig(c, i.guildId); await updateSecurityConfig(c, i.guildId, { antiRaid: { punishment: cycle(x.antiRaid.punishment, RAID_ACTIONS) } }); return panel(i, c, 'raid'); } }, + { name: 'raid_joins2', execute: async (i, c) => { if (!ok(i)) return deny(i); const x = await getSecurityConfig(c, i.guildId); await updateSecurityConfig(c, i.guildId, { antiRaid: { joins: x.antiRaid.joins >= 50 ? 2 : x.antiRaid.joins + 2 } }); return panel(i, c, 'raid'); } }, + { name: 'raid_window2', execute: async (i, c) => { if (!ok(i)) return deny(i); const x = await getSecurityConfig(c, i.guildId); await updateSecurityConfig(c, i.guildId, { antiRaid: { windowMs: cycle(x.antiRaid.windowMs, [5000, 10000, 15000, 30000, 60000]) } }); return panel(i, c, 'raid'); } }, + { name: 'raid_age2', execute: async (i, c) => { if (!ok(i)) return deny(i); const x = await getSecurityConfig(c, i.guildId); await updateSecurityConfig(c, i.guildId, { antiRaid: { minAccountAgeMs: cycle(x.antiRaid.minAccountAgeMs, [0, 3600000, 21600000, 86400000, 604800000, 2592000000]) } }); return panel(i, c, 'raid'); } }, + { name: 'raid_lock2', execute: async (i, c) => { if (!ok(i)) return deny(i); const x = await getSecurityConfig(c, i.guildId); await updateSecurityConfig(c, i.guildId, { antiRaid: { lockdown: !x.antiRaid.lockdown } }); return panel(i, c, 'raid'); } }, + { name: 'punishment_rules2', execute: async (i, c) => ok(i) ? panel(i, c, 'punishments') : deny(i) }, + { name: 'punishments_back2', execute: async (i, c) => ok(i) ? panel(i, c, 'punishments') : deny(i) }, + { name: 'punishment_decay2', execute: async (i, c) => { if (!ok(i)) return deny(i); const x = await getSecurityConfig(c, i.guildId); await updateSecurityConfig(c, i.guildId, { strikeDecayMs: x.strikeDecayMs >= 30 * 86400000 ? 3600000 : x.strikeDecayMs + 3600000 }); return panel(i, c, 'punishments'); } }, + { name: 'settings_toggle2', execute: async (i, c) => { if (!ok(i)) return deny(i); const x = await getSecurityConfig(c, i.guildId); await updateSecurityConfig(c, i.guildId, { enabled: !x.enabled }); return panel(i, c, 'settings'); } }, + { name: 'settings_decay2', execute: async (i, c) => { if (!ok(i)) return deny(i); const x = await getSecurityConfig(c, i.guildId); await updateSecurityConfig(c, i.guildId, { strikeDecayMs: x.strikeDecayMs >= 30 * 86400000 ? 3600000 : x.strikeDecayMs + 3600000 }); return panel(i, c, 'settings'); } }, +]; diff --git a/src/handlers/securityDashboardFixes.js b/src/handlers/securityDashboardFixes.js new file mode 100644 index 0000000000..501a78d6ef --- /dev/null +++ b/src/handlers/securityDashboardFixes.js @@ -0,0 +1,3 @@ +// Retired duplicate Security dashboard handlers. +// Canonical handlers are registered by securityDashboardCore.js and securityFinalOverrides.js. +export default []; diff --git a/src/handlers/securityDashboardHandlers.js b/src/handlers/securityDashboardHandlers.js new file mode 100644 index 0000000000..dc6a36ffbe --- /dev/null +++ b/src/handlers/securityDashboardHandlers.js @@ -0,0 +1,147 @@ +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, MessageFlags } from 'discord.js'; +import { getSecurityConfig, updateSecurityConfig, getStrikes, clearStrikes, sendSecurityLog } from '../services/security/securityService.js'; + +const NUKE_ACTIONS = ['strip', 'kick', 'ban']; +const RAID_ACTIONS = ['timeout', 'kick', 'ban']; +const AUTO_ACTIONS = ['delete', 'timeout', 'kick', 'ban']; +const NUKE_RULES = { + channelDelete: 'Channel Delete', channelCreate: 'Channel Create', roleDelete: 'Role Delete', roleCreate: 'Role Create', + roleUpdate: 'Role Update', webhookUpdate: 'Webhook Update', webhookDelete: 'Webhook Delete', ban: 'Ban', kick: 'Kick', botAdd: 'Bot Add', +}; +const AUTO_RULES = { spam: 'Spam', duplicate: 'Duplicate', mentions: 'Mentions', invites: 'Invites', links: 'Links', caps: 'Caps', badWords: 'Bad Words' }; + +const ok = i => i.customId.split(':').at(-1) === i.user.id; +const deny = i => i.reply({ content: 'This security dashboard belongs to another moderator.', flags: MessageFlags.Ephemeral }); +const B = (id, label, style = ButtonStyle.Secondary) => new ButtonBuilder().setCustomId(id).setLabel(label).setStyle(style); +const cycle = (v, list) => list[(list.indexOf(v) + 1 + list.length) % list.length]; +const row = (...buttons) => new ActionRowBuilder().addComponents(buttons); +const modalField = (id, label, value = '', style = TextInputStyle.Short) => new ActionRowBuilder().addComponents(new TextInputBuilder().setCustomId(id).setLabel(label).setStyle(style).setRequired(false).setValue(String(value).slice(0, 4000))); + +function embed(title, description, color = 0x5865f2, guild) { + return new EmbedBuilder().setAuthor({ name: 'Infinity Security Center', iconURL: guild.iconURL({ size: 128 }) || undefined }).setTitle(title).setDescription(description).setColor(color).setFooter({ text: 'Infinity System • Changes save automatically' }).setTimestamp(); +} + +export async function buildSecurityDashboard(client, guild, userId) { + const config = await getSecurityConfig(client, guild.id); + return { + embeds: [embed('🛡️ Server Protection', `**${guild.name}**\n\nChoose a security system to configure.\n\n🛡️ Anti-Nuke — ${config.antiNuke.enabled ? '🟢' : '🔴'}\n🚨 Anti-Raid — ${config.antiRaid.enabled ? '🟢' : '🔴'}\n🤖 AutoMod — ${config.autoMod.enabled ? '🟢' : '🔴'}\n⚖️ Punishments — ${(config.escalation || []).length} escalation levels\n🏆 Strikes — management enabled\n👤 Whitelist — ${(config.whitelist.users.length + config.whitelist.roles.length + config.whitelist.bots.length)} entries\n📋 Logs — ${config.logChannelId ? `<#${config.logChannelId}>` : 'not configured'}`, 0x57f287, guild)], + components: [ + row( + B(`security_panel_nuke2:${userId}`, '🛡️ Anti-Nuke', ButtonStyle.Danger), + B(`security_panel_raid2:${userId}`, '🚨 Anti-Raid', ButtonStyle.Primary), + B(`security_panel_automod2:${userId}`, '🤖 AutoMod', ButtonStyle.Primary), + B(`security_panel_punishments2:${userId}`, '⚖️ Punishments', ButtonStyle.Primary), + ), + row( + B(`security_panel_strikes2:${userId}`, '🏆 Strikes', ButtonStyle.Danger), + B(`security_panel_whitelist2:${userId}`, '👤 Whitelist'), + B(`security_panel_logs2:${userId}`, '📋 Logs'), + B(`security_panel_settings2:${userId}`, '⚙️ Settings'), + B(`security_refresh:${userId}`, '🔄 Refresh', ButtonStyle.Success), + ), + ], + }; +} + +async function dashboard(i, c) { return i.update(await buildSecurityDashboard(c, i.guild, i.user.id)); } + +async function nuke(i, c) { + const x = await getSecurityConfig(c, i.guildId), t = x.antiNuke.thresholds; + const lines = Object.entries(NUKE_RULES).map(([k, n]) => `**${n}** — threshold \`${t[k] ?? 1}\` • punishment **${x.antiNuke.punishments[k] || x.antiNuke.action}**`).join('\n'); + return i.update({ embeds: [embed('🛡️ Anti-Nuke', `**Status:** ${x.antiNuke.enabled ? '🟢 ACTIVE' : '🔴 OFF'}\n**Window:** ${x.antiNuke.windowMs / 1000}s\n**Lockdown:** ${x.antiNuke.lockdown ? '🟢 ON' : '🔴 OFF'}\n\n${lines}`, 0xed4245, i.guild)], components: [row(B(`security_back2:${i.user.id}`, '← Back'), B(`nuke_toggle2:${i.user.id}`, x.antiNuke.enabled ? 'Disable' : 'Enable', x.antiNuke.enabled ? ButtonStyle.Success : ButtonStyle.Danger), B(`nuke_lock2:${i.user.id}`, `Lockdown: ${x.antiNuke.lockdown ? 'ON' : 'OFF'}`), B(`nuke_window2:${i.user.id}`, `Window: ${x.antiNuke.windowMs / 1000}s`)), row(B(`nuke_thresholds2:${i.user.id}`, '✏️ Thresholds', ButtonStyle.Primary), B(`nuke_rules2:${i.user.id}`, '⚖️ Rule Punishments', ButtonStyle.Primary))] }); +} +async function nukeRules(i, c) { + const x = await getSecurityConfig(c, i.guildId), keys = Object.keys(NUKE_RULES); + const rows = [row(B(`nuke_rules_back2:${i.user.id}`, '← Anti-Nuke'))]; + for (let n = 0; n < keys.length; n += 4) rows.push(row(...keys.slice(n, n + 4).map(k => B(`nuke_rule:${k}:${i.user.id}`, `${NUKE_RULES[k]}: ${x.antiNuke.punishments[k] || 'strip'}`, ButtonStyle.Primary)))); + return i.update({ embeds: [embed('⚖️ Anti-Nuke Punishments', 'Each Anti-Nuke event has its own punishment.', 0xed4245, i.guild)], components: rows.slice(0, 5) }); +} +async function raid(i, c) { + const x = await getSecurityConfig(c, i.guildId); + return i.update({ embeds: [embed('🚨 Anti-Raid', `**Status:** ${x.antiRaid.enabled ? '🟢 ACTIVE' : '🔴 OFF'}\n**Join burst:** ${x.antiRaid.joins} joins / ${x.antiRaid.windowMs / 1000}s\n**Minimum account age:** ${Math.round(x.antiRaid.minAccountAgeMs / 3600000)}h\n**Punishment:** **${x.antiRaid.punishment}**\n**Lockdown:** ${x.antiRaid.lockdown ? '🟢 ON' : '🔴 OFF'}`, 0xf47b67, i.guild)], components: [row(B(`security_back2:${i.user.id}`, '← Back'), B(`raid_toggle2:${i.user.id}`, x.antiRaid.enabled ? 'Disable' : 'Enable', x.antiRaid.enabled ? ButtonStyle.Success : ButtonStyle.Danger), B(`raid_punishment2:${i.user.id}`, `Punishment: ${x.antiRaid.punishment}`, ButtonStyle.Primary)), row(B(`raid_joins2:${i.user.id}`, `Joins: ${x.antiRaid.joins}`), B(`raid_window2:${i.user.id}`, `Window: ${x.antiRaid.windowMs / 1000}s`), B(`raid_age2:${i.user.id}`, `Age: ${Math.round(x.antiRaid.minAccountAgeMs / 3600000)}h`), B(`raid_lock2:${i.user.id}`, `Lockdown: ${x.antiRaid.lockdown ? 'ON' : 'OFF'}`))] }); +} +async function automod(i, c) { + const x = await getSecurityConfig(c, i.guildId); + const text = Object.entries(AUTO_RULES).map(([k, n]) => `${n}: **${x.autoMod[k]?.punishment || 'delete'}**`).join('\n'); + const keys = Object.keys(AUTO_RULES); + const rows = [row(B(`security_back2:${i.user.id}`, '← Back'), B(`automod_toggle2:${i.user.id}`, x.autoMod.enabled ? 'Disable' : 'Enable', x.autoMod.enabled ? ButtonStyle.Success : ButtonStyle.Danger))]; + for (let n = 0; n < keys.length; n += 4) rows.push(row(...keys.slice(n, n + 4).map(k => B(`auto_pun2:${k}:${i.user.id}`, `${AUTO_RULES[k]}: ${x.autoMod[k]?.punishment || 'delete'}`, ButtonStyle.Primary)))); + return i.update({ embeds: [embed('🤖 AutoMod', text, 0x5865f2, i.guild)], components: rows.slice(0, 3) }); +} +async function punishments(i, c) { + const x = await getSecurityConfig(c, i.guildId); + const escalation = (x.escalation || []).map(e => `Strike **${e.strike}** → **${e.action}**${e.durationMs ? ` (${Math.round(e.durationMs / 60000)}m)` : ''}`).join('\n') || 'No escalation rules.'; + return i.update({ embeds: [embed('⚖️ Punishments & Escalation', `Every security rule has an independent punishment.\n\n**Anti-Raid:** ${x.antiRaid.punishment}\n**Strike decay:** ${Math.round(x.strikeDecayMs / 3600000)}h\n\n**Escalation**\n${escalation}`, 0xfee75c, i.guild)], components: [row(B(`security_back2:${i.user.id}`, '← Back'), B(`punishment_decay2:${i.user.id}`, '⏱️ Decay', ButtonStyle.Primary))] }); +} +async function punishmentRules(i, c) { return punishments(i, c); } +async function whitelist(i, c) { + const x = await getSecurityConfig(c, i.guildId); + return i.update({ embeds: [embed('👤 Whitelist', `Trusted entries bypass applicable security enforcement.\n\n**Users:** ${x.whitelist.users.length}\n**Roles:** ${x.whitelist.roles.length}\n**Bots:** ${x.whitelist.bots.length}`, 0x57f287, i.guild)], components: [row(B(`security_back2:${i.user.id}`, '← Back'), B(`wl_users2:${i.user.id}`, '👤 Users', ButtonStyle.Primary), B(`wl_roles2:${i.user.id}`, '🎭 Roles', ButtonStyle.Primary), B(`wl_bots2:${i.user.id}`, '🤖 Bots', ButtonStyle.Primary))] }); +} +async function logs(i, c) { const x = await getSecurityConfig(c, i.guildId); return i.update({ embeds: [embed('📋 Security Logs', `**Log channel:** ${x.logChannelId ? `<#${x.logChannelId}>` : 'Not configured'}\n**Ignored channels:** ${x.ignoredChannels.length}`, 0x5865f2, i.guild)], components: [row(B(`security_back2:${i.user.id}`, '← Back'), B(`logs_channel2:${i.user.id}`, '📋 Set Log Channel', ButtonStyle.Primary), B(`logs_ignored2:${i.user.id}`, '🚫 Ignored Channels'))] }); } +async function settings(i, c) { const x = await getSecurityConfig(c, i.guildId); return i.update({ embeds: [embed('⚙️ Security Settings', `**Global protection:** ${x.enabled ? '🟢 ON' : '🔴 OFF'}\n**Strike decay:** ${Math.round(x.strikeDecayMs / 3600000)}h\n**Ignored channels:** ${x.ignoredChannels.length}`, 0x57f287, i.guild)], components: [row(B(`security_back2:${i.user.id}`, '← Back'), B(`settings_toggle2:${i.user.id}`, x.enabled ? '🔴 Disable Protection' : '🟢 Enable Protection'), B(`settings_decay2:${i.user.id}`, '⏱️ Strike Decay', ButtonStyle.Primary), B(`settings_ignored2:${i.user.id}`, '🚫 Ignored Channels', ButtonStyle.Primary))] }); } +async function strikes(i, c) { + const members = await i.guild.members.fetch().catch(() => i.guild.members.cache), entries = []; + for (const m of members.values()) { if (m.user.bot) continue; const s = await getStrikes(c, i.guildId, m.id).catch(() => ({ count: 0 })); if (Number(s.count) > 0) entries.push({ id: m.id, count: Number(s.count) }); } + entries.sort((a, b) => b.count - a.count); + const text = entries.slice(0, 10).map((e, n) => `${n + 1}. <@${e.id}> — **${e.count}** strikes`).join('\n') || 'No active strikes.'; + const rows = [row(B(`security_back2:${i.user.id}`, '← Back'), B(`strikes_refresh2:${i.user.id}`, '🔄 Refresh', ButtonStyle.Success))]; + if (entries.length) rows.push(row(...entries.slice(0, 4).map(e => B(`strike_manage2:${e.id}:${i.user.id}`, `Manage ${e.count}`, ButtonStyle.Primary)))); + return i.update({ embeds: [embed('🏆 Strikes', `Members with active security strikes.\n\n${text}`, 0xfee75c, i.guild)], components: rows }); +} +async function member(i, c, userId) { + const s = await getStrikes(c, i.guildId, userId).catch(() => ({ count: 0, lastReason: '' })); + return i.update({ embeds: [embed('👤 Security Strikes', `<@${userId}>\n\n**Strikes:** ${Number(s.count) || 0}\n**Last strike:** ${s.lastReason || '—'}`, 0xfee75c, i.guild)], components: [row(B(`strike_reset2:${userId}:${i.user.id}`, '🧹 Reset Strikes', ButtonStyle.Danger), B(`strikes_back2:${i.user.id}`, '← Back'))] }); +} + +const panelMap = { security_panel_nuke2: nuke, security_panel_raid2: raid, security_panel_automod2: automod, security_panel_punishments2: punishments, security_panel_whitelist2: whitelist, security_panel_logs2: logs, security_panel_settings2: settings, security_panel_strikes2: strikes }; + +export const securityDashboardButtonHandlers = [ + ...Object.entries(panelMap).map(([name, fn]) => ({ name, execute: async (i, c) => ok(i) ? fn(i, c) : deny(i) })), + { name: 'security_back2', execute: async (i, c) => ok(i) ? dashboard(i, c) : deny(i) }, + { name: 'security_refresh', execute: async (i, c) => ok(i) ? dashboard(i, c) : deny(i) }, + { name: 'nuke_toggle2', execute: async (i, c) => { if (!ok(i)) return deny(i); const x = await getSecurityConfig(c, i.guildId); await updateSecurityConfig(c, i.guildId, { antiNuke: { enabled: !x.antiNuke.enabled } }); return nuke(i, c); } }, + { name: 'nuke_lock2', execute: async (i, c) => { if (!ok(i)) return deny(i); const x = await getSecurityConfig(c, i.guildId); await updateSecurityConfig(c, i.guildId, { antiNuke: { lockdown: !x.antiNuke.lockdown } }); return nuke(i, c); } }, + { name: 'nuke_window2', execute: async (i, c) => { if (!ok(i)) return deny(i); const x = await getSecurityConfig(c, i.guildId); await updateSecurityConfig(c, i.guildId, { antiNuke: { windowMs: cycle(x.antiNuke.windowMs, [5000,10000,15000,30000,60000]) } }); return nuke(i, c); } }, + { name: 'nuke_thresholds2', execute: async i => ok(i) ? i.showModal(new ModalBuilder().setCustomId(`nuke_thresholds_modal2:${i.user.id}`).setTitle('Anti-Nuke Thresholds').addComponents(modalField('channelDelete','Channel deletes','3'),modalField('channelCreate','Channel creates','5'),modalField('roleDelete','Role deletes','3'),modalField('roleCreate','Role creates','5'),modalField('botAdd','Bot additions','1'))) : deny(i) }, + { name: 'nuke_rules2', execute: async (i,c) => ok(i) ? nukeRules(i,c) : deny(i) }, + { name: 'nuke_rules_back2', execute: async (i,c) => ok(i) ? nuke(i,c) : deny(i) }, + ...Object.keys(NUKE_RULES).map(k => ({ name: 'nuke_rule', match: k, execute: async (i,c) => { if (!ok(i)) return deny(i); const x=await getSecurityConfig(c,i.guildId); await updateSecurityConfig(c,i.guildId,{antiNuke:{punishments:{[k]:cycle(x.antiNuke.punishments[k]||'strip',NUKE_ACTIONS)}}}); return nukeRules(i,c); } })), + { name: 'raid_toggle2', execute: async(i,c)=>{if(!ok(i))return deny(i);const x=await getSecurityConfig(c,i.guildId);await updateSecurityConfig(c,i.guildId,{antiRaid:{enabled:!x.antiRaid.enabled}});return raid(i,c);} }, + { name: 'raid_punishment2', execute: async(i,c)=>{if(!ok(i))return deny(i);const x=await getSecurityConfig(c,i.guildId);await updateSecurityConfig(c,i.guildId,{antiRaid:{punishment:cycle(x.antiRaid.punishment,RAID_ACTIONS)}});return raid(i,c);} }, + { name: 'raid_joins2', execute: async(i,c)=>{if(!ok(i))return deny(i);const x=await getSecurityConfig(c,i.guildId);await updateSecurityConfig(c,i.guildId,{antiRaid:{joins:x.antiRaid.joins>=50?2:x.antiRaid.joins+2}});return raid(i,c);} }, + { name: 'raid_window2', execute: async(i,c)=>{if(!ok(i))return deny(i);const x=await getSecurityConfig(c,i.guildId);await updateSecurityConfig(c,i.guildId,{antiRaid:{windowMs:cycle(x.antiRaid.windowMs,[5000,10000,15000,30000,60000])}});return raid(i,c);} }, + { name: 'raid_age2', execute: async(i,c)=>{if(!ok(i))return deny(i);const x=await getSecurityConfig(c,i.guildId);await updateSecurityConfig(c,i.guildId,{antiRaid:{minAccountAgeMs:cycle(x.antiRaid.minAccountAgeMs,[0,3600000,21600000,86400000,604800000,2592000000])}});return raid(i,c);} }, + { name: 'raid_lock2', execute: async(i,c)=>{if(!ok(i))return deny(i);const x=await getSecurityConfig(c,i.guildId);await updateSecurityConfig(c,i.guildId,{antiRaid:{lockdown:!x.antiRaid.lockdown}});return raid(i,c);} }, + { name: 'punishment_rules2', execute: async(i,c)=>ok(i)?punishmentRules(i,c):deny(i) }, + { name: 'punishments_back2', execute: async(i,c)=>ok(i)?punishments(i,c):deny(i) }, + { name: 'pun_raid2', execute: async(i,c)=>{if(!ok(i))return deny(i);const x=await getSecurityConfig(c,i.guildId);await updateSecurityConfig(c,i.guildId,{antiRaid:{punishment:cycle(x.antiRaid.punishment,RAID_ACTIONS)}});return punishmentRules(i,c);} }, + { name: 'pun_auto2', execute: async(i,c)=>ok(i)?automod(i,c):deny(i) }, + { name: 'automod_toggle2', execute: async(i,c)=>{if(!ok(i))return deny(i);const x=await getSecurityConfig(c,i.guildId);await updateSecurityConfig(c,i.guildId,{autoMod:{enabled:!x.autoMod.enabled}});return automod(i,c);} }, + ...Object.keys(AUTO_RULES).map(k => ({ name: 'auto_pun2', match: k, execute: async(i,c)=>{if(!ok(i))return deny(i);const x=await getSecurityConfig(c,i.guildId);await updateSecurityConfig(c,i.guildId,{autoMod:{[k]:{punishment:cycle(x.autoMod[k]?.punishment||'delete',AUTO_ACTIONS)}}});return automod(i,c);} })), + { name: 'punishment_decay2', execute: async(i,c)=>{if(!ok(i))return deny(i);const x=await getSecurityConfig(c,i.guildId);await updateSecurityConfig(c,i.guildId,{strikeDecayMs:x.strikeDecayMs>=30*86400000?3600000:x.strikeDecayMs+3600000});return punishments(i,c);} }, + { name: 'strikes_refresh2', execute: async(i,c)=>ok(i)?strikes(i,c):deny(i) }, + { name: 'strike_manage2', execute: async(i,c)=>ok(i)?member(i,c,i.customId.split(':').at(-2)):deny(i) }, + { name: 'strike_reset2', execute: async(i,c)=>{if(!ok(i))return deny(i);const u=i.customId.split(':').at(-2);await clearStrikes(c,i.guildId,u);await sendSecurityLog(c,i.guild,{title:'Strikes Reset',description:`<@${u}> strikes reset by <@${i.user.id}>`,color:0x57f287});return strikes(i,c);} }, + { name: 'strikes_back2', execute: async(i,c)=>ok(i)?strikes(i,c):deny(i) }, + { name: 'wl_users2', execute: async i=>ok(i)?i.showModal(new ModalBuilder().setCustomId(`wl_users_modal2:${i.user.id}`).setTitle('Whitelist Users').addComponents(modalField('value','User IDs, one per line','',TextInputStyle.Paragraph))):deny(i) }, + { name: 'wl_roles2', execute: async i=>ok(i)?i.showModal(new ModalBuilder().setCustomId(`wl_roles_modal2:${i.user.id}`).setTitle('Whitelist Roles').addComponents(modalField('value','Role IDs, one per line','',TextInputStyle.Paragraph))):deny(i) }, + { name: 'wl_bots2', execute: async i=>ok(i)?i.showModal(new ModalBuilder().setCustomId(`wl_bots_modal2:${i.user.id}`).setTitle('Whitelist Bots').addComponents(modalField('value','Bot IDs, one per line','',TextInputStyle.Paragraph))):deny(i) }, + { name: 'logs_channel2', execute: async i=>ok(i)?i.showModal(new ModalBuilder().setCustomId(`logs_channel_modal2:${i.user.id}`).setTitle('Security Log Channel').addComponents(modalField('value','Channel ID'))):deny(i) }, + { name: 'logs_ignored2', execute: async i=>ok(i)?i.showModal(new ModalBuilder().setCustomId(`logs_ignored_modal2:${i.user.id}`).setTitle('Ignored Channels').addComponents(modalField('value','Channel IDs, one per line','',TextInputStyle.Paragraph))):deny(i) }, + { name: 'settings_toggle2', execute: async(i,c)=>{if(!ok(i))return deny(i);const x=await getSecurityConfig(c,i.guildId);await updateSecurityConfig(c,i.guildId,{enabled:!x.enabled});return settings(i,c);} }, + { name: 'settings_decay2', execute: async(i,c)=>{if(!ok(i))return deny(i);const x=await getSecurityConfig(c,i.guildId);await updateSecurityConfig(c,i.guildId,{strikeDecayMs:x.strikeDecayMs>=30*86400000?3600000:x.strikeDecayMs+3600000});return settings(i,c);} }, + { name: 'settings_ignored2', execute: async i=>ok(i)?i.showModal(new ModalBuilder().setCustomId(`settings_ignored_modal2:${i.user.id}`).setTitle('Ignored Channels').addComponents(modalField('value','Channel IDs, one per line','',TextInputStyle.Paragraph))):deny(i) }, + { name: 'logs_ignored2', execute: async i=>ok(i)?i.showModal(new ModalBuilder().setCustomId(`logs_ignored_modal2:${i.user.id}`).setTitle('Ignored Channels').addComponents(modalField('value','Channel IDs, one per line','',TextInputStyle.Paragraph))):deny(i) }, + { name: 'logs_channel2', execute: async i=>ok(i)?i.showModal(new ModalBuilder().setCustomId(`logs_channel_modal2:${i.user.id}`).setTitle('Security Log Channel').addComponents(modalField('value','Channel ID'))):deny(i) }, +]; + +export const securityDashboardModalHandlers = [ + { name:'nuke_thresholds_modal2', execute:async(i,c)=>{if(!ok(i))return deny(i);const x=await getSecurityConfig(c,i.guildId);const t={...x.antiNuke.thresholds};for(const k of ['channelDelete','channelCreate','roleDelete','roleCreate','botAdd'])t[k]=Math.max(1,Number(i.fields.getTextInputValue(k))||t[k]);await updateSecurityConfig(c,i.guildId,{antiNuke:{thresholds:t}});return nuke(i,c);} }, + { name:'wl_users_modal2', execute:async(i,c)=>{if(!ok(i))return deny(i);const users=String(i.fields.getTextInputValue('value')||'').split(/\s+/).filter(Boolean).slice(0,100);await updateSecurityConfig(c,i.guildId,{whitelist:{users}});return whitelist(i,c);} }, + { name:'wl_roles_modal2', execute:async(i,c)=>{if(!ok(i))return deny(i);const roles=String(i.fields.getTextInputValue('value')||'').split(/\s+/).filter(Boolean).slice(0,100);await updateSecurityConfig(c,i.guildId,{whitelist:{roles}});return whitelist(i,c);} }, + { name:'wl_bots_modal2', execute:async(i,c)=>{if(!ok(i))return deny(i);const bots=String(i.fields.getTextInputValue('value')||'').split(/\s+/).filter(Boolean).slice(0,100);await updateSecurityConfig(c,i.guildId,{whitelist:{bots}});return whitelist(i,c);} }, + { name:'logs_channel_modal2', execute:async(i,c)=>{if(!ok(i))return deny(i);const value=String(i.fields.getTextInputValue('value')||'').trim()||null;await updateSecurityConfig(c,i.guildId,{logChannelId:value});return logs(i,c);} }, + { name:'logs_ignored_modal2', execute:async(i,c)=>{if(!ok(i))return deny(i);const value=String(i.fields.getTextInputValue('value')||'').split(/\s+/).filter(Boolean).slice(0,100);await updateSecurityConfig(c,i.guildId,{ignoredChannels:value});return logs(i,c);} }, + { name:'settings_ignored_modal2', execute:async(i,c)=>{if(!ok(i))return deny(i);const value=String(i.fields.getTextInputValue('value')||'').split(/\s+/).filter(Boolean).slice(0,100);await updateSecurityConfig(c,i.guildId,{ignoredChannels:value});return settings(i,c);} }, +]; \ No newline at end of file diff --git a/src/handlers/securityDashboardOverrides.js b/src/handlers/securityDashboardOverrides.js new file mode 100644 index 0000000000..fd66b899a5 --- /dev/null +++ b/src/handlers/securityDashboardOverrides.js @@ -0,0 +1,36 @@ +import { buildSecurityDashboard, buildSecurityControls } from '../commands/Security/security.js'; +import { getSecurityConfig } from '../services/security/securityService.js'; +import securityAutoModDashboard from './securityAutoModDashboard.js'; + +const ok = interaction => interaction.customId.split(':').at(-1) === interaction.user.id; +const deny = interaction => interaction.reply({ content: 'This security dashboard belongs to another moderator.', ephemeral: true }); + +async function main(interaction, client) { + if (!ok(interaction)) return deny(interaction); + const config = await getSecurityConfig(client, interaction.guildId); + return interaction.update({ + embeds: [buildSecurityDashboard(config, interaction.guild)], + components: buildSecurityControls(interaction.user.id), + }); +} + +async function automod(interaction, client) { + if (!ok(interaction)) return deny(interaction); + const handler = securityAutoModDashboard.find(h => h.name === 'security_panel_automod2'); + if (!handler) return interaction.reply({ content: 'AutoMod handler unavailable.', ephemeral: true }); + const original = interaction.customId; + interaction.customId = `security_panel_automod2:${interaction.user.id}`; + try { + return await handler.execute(interaction, client); + } finally { + interaction.customId = original; + } +} + +export default [ + { name: 'security_panel_automod', execute: automod }, + { name: 'security_main2', execute: main }, + { name: 'security_back2', execute: main }, + { name: 'security_back', execute: main }, + { name: 'security_refresh', execute: main }, +]; diff --git a/src/handlers/securityDashboardRuleHandlers.js b/src/handlers/securityDashboardRuleHandlers.js new file mode 100644 index 0000000000..b2bb1e8e6f --- /dev/null +++ b/src/handlers/securityDashboardRuleHandlers.js @@ -0,0 +1,25 @@ +import { getSecurityConfig, updateSecurityConfig } from '../services/security/securityService.js'; +import { ButtonStyle, ActionRowBuilder, ButtonBuilder, EmbedBuilder } from 'discord.js'; + +const NUKE = ['strip', 'kick', 'ban']; +const AUTO = ['delete', 'timeout', 'kick', 'ban']; +const AUTO_LABELS = { spam: 'Spam', duplicate: 'Duplicate', mentions: 'Mentions', invites: 'Invites', links: 'Links', caps: 'Caps', badWords: 'Bad Words' }; +const ok = i => i.customId.split(':').at(-1) === i.user.id; +const deny = i => i.reply({ content: 'This security dashboard belongs to another moderator.', ephemeral: true }); +const B = (id, label, style = ButtonStyle.Secondary) => new ButtonBuilder().setCustomId(id).setLabel(label).setStyle(style); +const row = (...b) => new ActionRowBuilder().addComponents(b); +const cycle = (v, list) => list[(list.indexOf(v) + 1) % list.length]; +const autoKeys = Object.keys(AUTO_LABELS); + +async function autoPage(i, c) { + const x = await getSecurityConfig(c, i.guildId); + const rows = [row(B(`punishments_back2:${i.user.id}`, '← Punishments'))]; + for (let n = 0; n < autoKeys.length; n += 4) rows.push(row(...autoKeys.slice(n, n + 4).map(k => B(`auto_pun2:${k}:${i.user.id}`, `${AUTO_LABELS[k]}: ${x.autoMod[k].punishment}`, ButtonStyle.Primary)))); + return i.update({ embeds: [new EmbedBuilder().setTitle('🤖 AutoMod Punishments').setDescription('Every AutoMod rule has an independent punishment. Click a rule to cycle: Delete → Timeout → Kick → Ban.').setColor(0x5865f2)], components: rows }); +} + +export default [ + { name: 'nuke_rule', execute: async (i, c) => { if (!ok(i)) return deny(i); const key = i.customId.split(':').at(-2); const x = await getSecurityConfig(c, i.guildId); const current = x.antiNuke.punishments[key] || 'strip'; const next = cycle(current, NUKE); await updateSecurityConfig(c, i.guildId, { antiNuke: { punishments: { [key]: next } } }); return i.update({ content: null, embeds: [new EmbedBuilder().setTitle('⚖️ Anti-Nuke Punishment Updated').setDescription(`**${key}** punishment is now **${next}**.`).setColor(0xed4245)], components: [row(B(`security_back2:${i.user.id}`, '← Back'))] }); } }, + { name: 'pun_auto2', execute: async (i, c) => ok(i) ? autoPage(i, c) : deny(i) }, + { name: 'auto_pun2', execute: async (i, c) => { if (!ok(i)) return deny(i); const key = i.customId.split(':').at(-2); const x = await getSecurityConfig(c, i.guildId); const next = cycle(x.autoMod[key].punishment, AUTO); await updateSecurityConfig(c, i.guildId, { autoMod: { [key]: { punishment: next } } }); return autoPage(i, c); } }, +]; diff --git a/src/handlers/securityHandlers.js b/src/handlers/securityHandlers.js new file mode 100644 index 0000000000..df7343b183 --- /dev/null +++ b/src/handlers/securityHandlers.js @@ -0,0 +1,183 @@ +import { ActionRowBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, MessageFlags } from 'discord.js'; +import { + getSecurityConfig, + updateSecurityConfig, + getStrikes, + clearStrikes, + sendSecurityLog, +} from '../services/security/securityService.js'; +import { + buildSecurityDashboard, + buildSecurityControls, + buildSecurityPanel, + buildSecurityPanelControls, + buildStrikeBoardEmbed, +} from '../commands/Security/security.js'; + +const PANELS = { + security_panel_nuke: 'nuke', + security_panel_raid: 'raid', + security_panel_automod: 'automod', + security_panel_punishments: 'punishments', + security_panel_strikes: 'strikes', + security_panel_whitelist: 'whitelist', + security_panel_logs: 'logs', + security_panel_settings: 'settings', +}; +const NUKE_ACTIONS = ['strip', 'kick', 'ban']; +const RAID_ACTIONS = ['timeout', 'kick', 'ban']; +const AUTO_ACTIONS = ['delete', 'warn', 'timeout', 'kick', 'ban']; +const NUKE_KEYS = ['channelDelete', 'channelCreate', 'roleDelete', 'roleCreate', 'roleUpdate', 'webhookUpdate', 'webhookDelete', 'ban', 'kick', 'botAdd']; +const AUTO_KEYS = ['spam', 'duplicate', 'mentions', 'invites', 'links', 'caps', 'badWords']; + +function authorized(i) { return i.customId.split(':').at(-1) === i.user.id; } +function reject(i) { return i.reply({ content: 'This security dashboard belongs to another moderator.', flags: MessageFlags.Ephemeral }); } +function num(v, fallback, min = 0) { const n = Number(v); return Number.isFinite(n) ? Math.max(min, n) : fallback; } +function lines(v) { return String(v || '').split(/[\s,]+/).map(x => x.trim()).filter(Boolean); } +function field(id, label, value = '', style = TextInputStyle.Short) { + const input = new TextInputBuilder().setCustomId(id).setLabel(label).setStyle(style).setRequired(false); + if (value !== undefined && value !== null && String(value)) input.setValue(String(value).slice(0, 4000)); + return new ActionRowBuilder().addComponents(input); +} +function modal(i, id, title, fields) { return i.showModal(new ModalBuilder().setCustomId(`${id}:${i.user.id}`).setTitle(title).addComponents(...fields)); } +function cycle(current, values) { const index = values.indexOf(current); return values[(index + 1) % values.length]; } + +async function panel(i, client, panelName) { + const config = await getSecurityConfig(client, i.guildId); + if (panelName === 'strikes') return strikeBoard(i, client); + return i.update({ embeds: [buildSecurityPanel(config, i.guild, panelName)], components: buildSecurityPanelControls(i.user.id, panelName, config) }); +} + +async function dashboard(i, client) { + const config = await getSecurityConfig(client, i.guildId); + return i.update({ embeds: [buildSecurityDashboard(config, i.guild)], components: buildSecurityControls(i.user.id) }); +} + +async function getStrikeEntries(client, guild) { + const config = await getSecurityConfig(client, guild.id); + const members = await guild.members.fetch().catch(() => guild.members.cache); + const now = Date.now(); + const entries = []; + for (const member of members.values()) { + if (member.user?.bot) continue; + const strike = await getStrikes(client, guild.id, member.id).catch(() => ({ count: 0, updatedAt: 0 })); + if (!strike?.count) continue; + if (config.strikeDecayMs && strike.updatedAt && now - strike.updatedAt > Number(config.strikeDecayMs)) continue; + entries.push({ userId: member.id, count: Number(strike.count || 0), updatedAt: strike.updatedAt || 0, lastReason: strike.lastReason || '' }); + } + return entries.sort((a, b) => b.count - a.count || b.updatedAt - a.updatedAt).slice(0, 10); +} + +async function strikeBoard(i, client) { + const entries = await getStrikeEntries(client, i.guild); + const embed = buildStrikeBoardEmbed(i.guild, entries); + const rows = []; + if (entries.length) { + for (let offset = 0; offset < entries.length; offset += 4) { + rows.push(new ActionRowBuilder().addComponents(...entries.slice(offset, offset + 4).map(entry => { + return new (requireButton())().setCustomId(`security_strike_reset:${entry.userId}:${i.user.id}`).setLabel(`Reset ${entry.userId.slice(-4)}`).setStyle(4); + }))); + } + } + rows.push(new ActionRowBuilder().addComponents( + new (requireButton())().setCustomId(`security_back:${i.user.id}`).setLabel('← Back').setStyle(2), + new (requireButton())().setCustomId(`security_strikes_refresh:${i.user.id}`).setLabel('🔄 Refresh').setStyle(3), + )); + return i.update({ embeds: [embed], components: rows.slice(0, 5) }); +} + +function requireButton() { + return class { + constructor() { this.data = {}; } + setCustomId(value) { this.data.custom_id = value; return this; } + setLabel(value) { this.data.label = value; return this; } + setStyle(value) { this.data.style = value; return this; } + toJSON() { return { type: 2, ...this.data }; } + }; +} + +const handlers = []; +for (const [name, p] of Object.entries(PANELS)) handlers.push({ name, execute: async (i, c) => authorized(i) ? panel(i, c, p) : reject(i) }); +handlers.push({ name: 'security_refresh', execute: async (i, c) => authorized(i) ? dashboard(i, c) : reject(i) }); +handlers.push({ name: 'security_back', execute: async (i, c) => authorized(i) ? dashboard(i, c) : reject(i) }); +handlers.push({ name: 'security_strikes_refresh', execute: async (i, c) => authorized(i) ? strikeBoard(i, c) : reject(i) }); +handlers.push({ + name: 'security_strike_reset', + execute: async (i, c) => { + if (!authorized(i)) return reject(i); + const parts = i.customId.split(':'); + const userId = parts.at(-2); + const member = await i.guild.members.fetch(userId).catch(() => null); + await clearStrikes(c, i.guildId, userId); + await sendSecurityLog(c, i.guild, { + title: 'Security Strikes Reset', + description: `Security strikes were reset for <@${userId}>.`, + color: 0x57f287, + fields: [ + { name: 'Member', value: member ? `${member.user.tag} (${userId})` : userId, inline: true }, + { name: 'Moderator', value: `${i.user.tag} (${i.user.id})`, inline: true }, + { name: 'Action', value: 'Reset all strikes', inline: true }, + ], + }); + return strikeBoard(i, c); + }, +}); + +handlers.push({ name: 'security_settings_refresh', execute: async (i, c) => authorized(i) ? panel(i, c, 'settings') : reject(i) }); +handlers.push({ name: 'security_settings_toggle', execute: async (i, c) => { if (!authorized(i)) return reject(i); const x = await getSecurityConfig(c, i.guildId); await updateSecurityConfig(c, i.guildId, { enabled: !x.enabled }); return panel(i, c, 'settings'); } }); + +handlers.push({ name: 'security_nuke_toggle', execute: async (i, c) => { if (!authorized(i)) return reject(i); const x = await getSecurityConfig(c, i.guildId); await updateSecurityConfig(c, i.guildId, { antiNuke: { enabled: !x.antiNuke.enabled } }); return panel(i, c, 'nuke'); } }); +handlers.push({ name: 'security_nuke_window', execute: async (i, c) => { if (!authorized(i)) return reject(i); const x = await getSecurityConfig(c, i.guildId); await updateSecurityConfig(c, i.guildId, { antiNuke: { windowMs: cycle(x.antiNuke.windowMs, [5000, 10000, 15000, 30000, 60000]) } }); return panel(i, c, 'nuke'); } }); +handlers.push({ name: 'security_nuke_lockdown', execute: async (i, c) => { if (!authorized(i)) return reject(i); const x = await getSecurityConfig(c, i.guildId); await updateSecurityConfig(c, i.guildId, { antiNuke: { lockdown: !x.antiNuke.lockdown } }); return panel(i, c, 'nuke'); } }); +handlers.push({ name: 'security_nuke_threshold', execute: async i => { if (!authorized(i)) return reject(i); const x = await getSecurityConfig(i.client, i.guildId); const t = x.antiNuke.thresholds || {}; return modal(i, 'security_nuke_threshold_modal', 'Anti-Nuke Thresholds', [field('channelDelete', 'Channel deletes', t.channelDelete), field('channelCreate', 'Channel creates', t.channelCreate), field('roleDelete', 'Role deletes', t.roleDelete), field('roleCreate', 'Role creates', t.roleCreate), field('botAdd', 'Bot additions', t.botAdd)]); } }); + +handlers.push({ name: 'security_raid_toggle', execute: async (i, c) => { if (!authorized(i)) return reject(i); const x = await getSecurityConfig(c, i.guildId); await updateSecurityConfig(c, i.guildId, { antiRaid: { enabled: !x.antiRaid.enabled } }); return panel(i, c, 'raid'); } }); +handlers.push({ name: 'security_raid_joins_down', execute: async (i, c) => { if (!authorized(i)) return reject(i); const x = await getSecurityConfig(c, i.guildId); await updateSecurityConfig(c, i.guildId, { antiRaid: { joins: Math.max(2, x.antiRaid.joins - 1) } }); return panel(i, c, 'raid'); } }); +handlers.push({ name: 'security_raid_joins_up', execute: async (i, c) => { if (!authorized(i)) return reject(i); const x = await getSecurityConfig(c, i.guildId); await updateSecurityConfig(c, i.guildId, { antiRaid: { joins: Math.min(100, x.antiRaid.joins + 1) } }); return panel(i, c, 'raid'); } }); +handlers.push({ name: 'security_raid_window', execute: async (i, c) => { if (!authorized(i)) return reject(i); const x = await getSecurityConfig(c, i.guildId); await updateSecurityConfig(c, i.guildId, { antiRaid: { windowMs: cycle(x.antiRaid.windowMs, [5000, 10000, 15000, 30000, 60000]) } }); return panel(i, c, 'raid'); } }); +handlers.push({ name: 'security_raid_age', execute: async (i, c) => { if (!authorized(i)) return reject(i); const x = await getSecurityConfig(c, i.guildId); await updateSecurityConfig(c, i.guildId, { antiRaid: { minAccountAgeMs: cycle(x.antiRaid.minAccountAgeMs, [0, 3600000, 21600000, 86400000, 604800000, 2592000000]) } }); return panel(i, c, 'raid'); } }); +handlers.push({ name: 'security_raid_lockdown', execute: async (i, c) => { if (!authorized(i)) return reject(i); const x = await getSecurityConfig(c, i.guildId); await updateSecurityConfig(c, i.guildId, { antiRaid: { lockdown: !x.antiRaid.lockdown } }); return panel(i, c, 'raid'); } }); +handlers.push({ name: 'security_raid_punishment', execute: async (i, c) => { if (!authorized(i)) return reject(i); const x = await getSecurityConfig(c, i.guildId); await updateSecurityConfig(c, i.guildId, { antiRaid: { punishment: cycle(x.antiRaid.punishment || 'timeout', RAID_ACTIONS) } }); return panel(i, c, 'raid'); } }); + +const toggleRule = async (i, c, key) => { if (!authorized(i)) return reject(i); const x = await getSecurityConfig(c, i.guildId); await updateSecurityConfig(c, i.guildId, { autoMod: { [key]: { enabled: !x.autoMod[key].enabled } } }); return panel(i, c, 'automod'); }; +const adjust = async (i, c, key, fieldName, delta, min, max) => { if (!authorized(i)) return reject(i); const x = await getSecurityConfig(c, i.guildId); await updateSecurityConfig(c, i.guildId, { autoMod: { [key]: { [fieldName]: Math.min(max, Math.max(min, x.autoMod[key][fieldName] + delta)) } } }); return panel(i, c, 'automod'); }; +handlers.push({ name: 'security_automod_toggle', execute: async (i, c) => { if (!authorized(i)) return reject(i); const x = await getSecurityConfig(c, i.guildId); await updateSecurityConfig(c, i.guildId, { autoMod: { enabled: !x.autoMod.enabled } }); return panel(i, c, 'automod'); } }); +handlers.push({ name: 'security_automod_spam_toggle', execute: (i, c) => toggleRule(i, c, 'spam') }); +handlers.push({ name: 'security_automod_dup_toggle', execute: (i, c) => toggleRule(i, c, 'duplicate') }); +handlers.push({ name: 'security_automod_invites', execute: (i, c) => toggleRule(i, c, 'invites') }); +handlers.push({ name: 'security_automod_links', execute: (i, c) => toggleRule(i, c, 'links') }); +handlers.push({ name: 'security_automod_caps', execute: (i, c) => toggleRule(i, c, 'caps') }); +handlers.push({ name: 'security_automod_spam_down', execute: (i, c) => adjust(i, c, 'spam', 'maxMessages', -1, 2, 30) }); +handlers.push({ name: 'security_automod_spam_up', execute: (i, c) => adjust(i, c, 'spam', 'maxMessages', 1, 2, 30) }); +handlers.push({ name: 'security_automod_dup_down', execute: (i, c) => adjust(i, c, 'duplicate', 'maxRepeats', -1, 2, 15) }); +handlers.push({ name: 'security_automod_dup_up', execute: (i, c) => adjust(i, c, 'duplicate', 'maxRepeats', 1, 2, 15) }); +handlers.push({ name: 'security_automod_mentions_down', execute: (i, c) => adjust(i, c, 'mentions', 'max', -1, 1, 30) }); +handlers.push({ name: 'security_automod_mentions_up', execute: (i, c) => adjust(i, c, 'mentions', 'max', 1, 1, 30) }); +handlers.push({ name: 'security_automod_action', execute: async (i, c) => { if (!authorized(i)) return reject(i); const x = await getSecurityConfig(c, i.guildId); await updateSecurityConfig(c, i.guildId, { autoMod: { action: cycle(x.autoMod.action || 'delete', AUTO_ACTIONS) } }); return panel(i, c, 'automod'); } }); +handlers.push({ name: 'security_automod_badwords', execute: async (i, c) => { if (!authorized(i)) return reject(i); const x = await getSecurityConfig(c, i.guildId); return modal(i, 'security_automod_badwords_modal', 'AutoMod Blocked Words', [field('words', 'Words, separated by spaces', (x.autoMod.badWords.words || []).join(' '), TextInputStyle.Paragraph)]); } }); +for (const key of AUTO_KEYS) handlers.push({ name: `security_automod_${key}_punishment`, execute: async (i, c) => { if (!authorized(i)) return reject(i); const x = await getSecurityConfig(c, i.guildId); await updateSecurityConfig(c, i.guildId, { autoMod: { [key]: { punishment: cycle(x.autoMod[key].punishment || 'delete', AUTO_ACTIONS) } } }); return panel(i, c, 'automod'); } }); + +handlers.push({ name: 'security_pun_decay_down', execute: async (i, c) => { if (!authorized(i)) return reject(i); const x = await getSecurityConfig(c, i.guildId); await updateSecurityConfig(c, i.guildId, { strikeDecayMs: Math.max(3600000, x.strikeDecayMs - 3600000) }); return panel(i, c, 'punishments'); } }); +handlers.push({ name: 'security_pun_decay_up', execute: async (i, c) => { if (!authorized(i)) return reject(i); const x = await getSecurityConfig(c, i.guildId); await updateSecurityConfig(c, i.guildId, { strikeDecayMs: Math.min(30 * 86400000, x.strikeDecayMs + 3600000) }); return panel(i, c, 'punishments'); } }); +handlers.push({ name: 'security_pun_raid', execute: async (i, c) => { if (!authorized(i)) return reject(i); const x = await getSecurityConfig(c, i.guildId); await updateSecurityConfig(c, i.guildId, { antiRaid: { punishment: cycle(x.antiRaid.punishment || 'timeout', RAID_ACTIONS) } }); return panel(i, c, 'punishments'); } }); +for (const key of NUKE_KEYS) handlers.push({ name: `security_pun_nuke_${key}`, execute: async (i, c) => { if (!authorized(i)) return reject(i); const x = await getSecurityConfig(c, i.guildId); await updateSecurityConfig(c, i.guildId, { antiNuke: { punishments: { [key]: cycle(x.antiNuke.punishments[key] || 'strip', NUKE_ACTIONS) } } }); return panel(i, c, 'punishments'); } }); +for (const key of AUTO_KEYS) handlers.push({ name: `security_pun_auto_${key}`, execute: async (i, c) => { if (!authorized(i)) return reject(i); const x = await getSecurityConfig(c, i.guildId); await updateSecurityConfig(c, i.guildId, { autoMod: { [key]: { punishment: cycle(x.autoMod[key].punishment || 'delete', AUTO_ACTIONS) } } }); return panel(i, c, 'punishments'); } }); +for (let strike = 1; strike <= 10; strike++) handlers.push({ name: `security_pun_level_${strike}`, execute: async (i, c) => { if (!authorized(i)) return reject(i); const x = await getSecurityConfig(c, i.guildId); const escalation = x.escalation.map(e => e.strike === strike ? { ...e, action: cycle(e.action, ['warn', 'timeout', 'kick', 'ban']) } : e); await updateSecurityConfig(c, i.guildId, { escalation }); return panel(i, c, 'punishments'); } }); + +handlers.push({ name: 'security_whitelist_users', execute: async (i, c) => { if (!authorized(i)) return reject(i); const x = await getSecurityConfig(c, i.guildId); return modal(i, 'security_whitelist_users_modal', 'Whitelist Users', [field('users', 'User IDs, one per line', (x.whitelist.users || []).join('\n'), TextInputStyle.Paragraph)]); } }); +handlers.push({ name: 'security_whitelist_roles', execute: async (i, c) => { if (!authorized(i)) return reject(i); const x = await getSecurityConfig(c, i.guildId); return modal(i, 'security_whitelist_roles_modal', 'Whitelist Roles', [field('roles', 'Role IDs, one per line', (x.whitelist.roles || []).join('\n'), TextInputStyle.Paragraph)]); } }); +handlers.push({ name: 'security_whitelist_bots', execute: async (i, c) => { if (!authorized(i)) return reject(i); const x = await getSecurityConfig(c, i.guildId); return modal(i, 'security_whitelist_bots_modal', 'Whitelist Bots', [field('bots', 'Bot IDs, one per line', (x.whitelist.bots || []).join('\n'), TextInputStyle.Paragraph)]); } }); +handlers.push({ name: 'security_logs_channel', execute: async i => { if (!authorized(i)) return reject(i); const x = await getSecurityConfig(i.client, i.guildId); return modal(i, 'security_logs_channel_modal', 'Security Log Channel', [field('channel', 'Channel ID', x.logChannelId || '')]); } }); +handlers.push({ name: 'security_logs_ignored', execute: async i => { if (!authorized(i)) return reject(i); const x = await getSecurityConfig(i.client, i.guildId); return modal(i, 'security_logs_ignored_modal', 'Ignored Channels', [field('channels', 'Channel IDs, one per line', (x.ignoredChannels || []).join('\n'), TextInputStyle.Paragraph)]); } }); + +export const securityButtonHandlers = handlers; + +export const securityModalHandlers = [ + { name: 'security_nuke_threshold_modal', execute: async (i, c) => { if (!authorized(i)) return reject(i); const x = await getSecurityConfig(c, i.guildId); const t = { ...x.antiNuke.thresholds }; for (const key of ['channelDelete', 'channelCreate', 'roleDelete', 'roleCreate', 'botAdd']) t[key] = num(i.fields.getTextInputValue(key), t[key], 1); await updateSecurityConfig(c, i.guildId, { antiNuke: { thresholds: t } }); return panel(i, c, 'nuke'); } }, + { name: 'security_automod_badwords_modal', execute: async (i, c) => { if (!authorized(i)) return reject(i); const words = lines(i.fields.getTextInputValue('words')).slice(0, 100); await updateSecurityConfig(c, i.guildId, { autoMod: { badWords: { enabled: words.length > 0, words } } }); return panel(i, c, 'automod'); } }, + { name: 'security_whitelist_users_modal', execute: async (i, c) => { if (!authorized(i)) return reject(i); await updateSecurityConfig(c, i.guildId, { whitelist: { users: lines(i.fields.getTextInputValue('users')).slice(0, 100) } }); return panel(i, c, 'whitelist'); } }, + { name: 'security_whitelist_roles_modal', execute: async (i, c) => { if (!authorized(i)) return reject(i); await updateSecurityConfig(c, i.guildId, { whitelist: { roles: lines(i.fields.getTextInputValue('roles')).slice(0, 100) } }); return panel(i, c, 'whitelist'); } }, + { name: 'security_whitelist_bots_modal', execute: async (i, c) => { if (!authorized(i)) return reject(i); await updateSecurityConfig(c, i.guildId, { whitelist: { bots: lines(i.fields.getTextInputValue('bots')).slice(0, 100) } }); return panel(i, c, 'whitelist'); } }, + { name: 'security_logs_channel_modal', execute: async (i, c) => { if (!authorized(i)) return reject(i); await updateSecurityConfig(c, i.guildId, { logChannelId: i.fields.getTextInputValue('channel').trim() || null }); return panel(i, c, 'logs'); } }, + { name: 'security_logs_ignored_modal', execute: async (i, c) => { if (!authorized(i)) return reject(i); await updateSecurityConfig(c, i.guildId, { ignoredChannels: lines(i.fields.getTextInputValue('channels')).slice(0, 100) }); return panel(i, c, 'logs'); } }, +]; \ No newline at end of file diff --git a/src/handlers/securityPanelOverrides.js b/src/handlers/securityPanelOverrides.js new file mode 100644 index 0000000000..9f01b93db7 --- /dev/null +++ b/src/handlers/securityPanelOverrides.js @@ -0,0 +1,50 @@ +import { securityDashboardButtonHandlers } from './securityDashboardHandlers.js'; +import securityAutoModDashboard from './securityAutoModDashboard.js'; + +// Keep old /security panel button IDs compatible with the new dashboard. +const names = new Set([ + 'security_panel_nuke', + 'security_panel_raid', + 'security_panel_automod', + 'security_panel_punishments', + 'security_panel_strikes', + 'security_panel_whitelist', + 'security_panel_logs', + 'security_panel_settings', +]); + +const target = { + security_panel_nuke: 'security_panel_nuke2', + security_panel_raid: 'security_panel_raid2', + security_panel_automod: 'security_panel_automod2', + security_panel_punishments: 'security_panel_punishments2', + security_panel_strikes: 'security_panel_strikes2', + security_panel_whitelist: 'security_panel_whitelist2', + security_panel_logs: 'security_panel_logs2', + security_panel_settings: 'security_panel_settings2', +}; + +export default [...names].map(name => ({ + name, + execute: async (interaction, client) => { + const targetName = target[name]; + const handlers = [...securityDashboardButtonHandlers, ...securityAutoModDashboard]; + const handler = handlers.find(h => h.name === targetName); + + if (!handler) { + return interaction.reply({ + content: 'Security panel handler unavailable.', + ephemeral: true, + }); + } + + const original = interaction.customId; + interaction.customId = `${targetName}:${interaction.user.id}`; + + try { + return await handler.execute(interaction, client); + } finally { + interaction.customId = original; + } + }, +})); diff --git a/src/interactions/buttons/partner.js b/src/interactions/buttons/partner.js new file mode 100644 index 0000000000..7c59d61d57 --- /dev/null +++ b/src/interactions/buttons/partner.js @@ -0,0 +1,68 @@ +import { ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder, EmbedBuilder, ButtonBuilder, ButtonStyle, PermissionFlagsBits } from 'discord.js'; +import { getPartnerData, savePartnerData, applicationEmbed, applicationButtons } from '../../utils/partner.js'; + +async function renderApplications(interaction, filter) { + const data = await getPartnerData(interaction.client, interaction.guildId); + const items = filter === 'active' ? data.partners.filter(p => p.status === 'active') : data.applications.filter(a => a.status === 'pending'); + const title = filter === 'active' ? '🤝 الشراكات الحالية' : '🟡 طلبات الشراكة المعلقة'; + if (!items.length) return interaction.update({ embeds: [new EmbedBuilder().setColor(0x5865f2).setTitle(title).setDescription('لا توجد بيانات لعرضها حاليًا.')], components: [new ActionRowBuilder().addComponents(new ButtonBuilder().setCustomId('partner_back_dashboard').setLabel('رجوع').setStyle(ButtonStyle.Secondary))] }); + const description = items.slice(0, 15).map((x, i) => filter === 'active' ? `**${i + 1}. ${x.serverName}** • ${x.members} عضو • ` : `**#${x.id} — ${x.serverName}** • ${x.members} عضو • <@${x.applicantId}>`).join('\n'); + return interaction.update({ embeds: [new EmbedBuilder().setColor(0x5865f2).setTitle(title).setDescription(description)], components: [new ActionRowBuilder().addComponents(new ButtonBuilder().setCustomId('partner_back_dashboard').setLabel('رجوع').setStyle(ButtonStyle.Secondary))] }); +} + +export default [ + { name: 'partner_apply', async execute(interaction) { + const modal = new ModalBuilder().setCustomId('partner_apply_modal').setTitle('طلب شراكة').addComponents( + new ActionRowBuilder().addComponents(new TextInputBuilder().setCustomId('server_name').setLabel('اسم السيرفر').setPlaceholder('اكتب اسم سيرفرك').setStyle(TextInputStyle.Short).setRequired(true).setMaxLength(100)), + new ActionRowBuilder().addComponents(new TextInputBuilder().setCustomId('invite').setLabel('رابط الدعوة').setPlaceholder('https://discord.gg/...').setStyle(TextInputStyle.Short).setRequired(true).setMaxLength(200)), + new ActionRowBuilder().addComponents(new TextInputBuilder().setCustomId('members').setLabel('عدد الأعضاء').setPlaceholder('مثال: 150').setStyle(TextInputStyle.Short).setRequired(true).setMaxLength(10)), + new ActionRowBuilder().addComponents(new TextInputBuilder().setCustomId('description').setLabel('وصف السيرفر').setPlaceholder('عرفنا بسيرفرك ومحتواه').setStyle(TextInputStyle.Paragraph).setRequired(true).setMaxLength(1000)), + ); + return interaction.showModal(modal); + }}, + { name: 'partner_pending', async execute(interaction) { return renderApplications(interaction, 'pending'); } }, + { name: 'partner_active', async execute(interaction) { return renderApplications(interaction, 'active'); } }, + { name: 'partner_stats', async execute(interaction) { + const data = await getPartnerData(interaction.client, interaction.guildId); + const accepted = data.applications.filter(a => a.status === 'accepted').length, rejected = data.applications.filter(a => a.status === 'rejected').length, pending = data.applications.filter(a => a.status === 'pending').length; + return interaction.update({ embeds: [new EmbedBuilder().setColor(0x5865f2).setTitle('📊 إحصائيات الشراكات').addFields({ name: 'الشراكات الحالية', value: String(data.partners.filter(p => p.status === 'active').length), inline: true }, { name: 'المقبولة', value: String(accepted), inline: true }, { name: 'المرفوضة', value: String(rejected), inline: true }, { name: 'المعلقة', value: String(pending), inline: true }, { name: 'إجمالي الطلبات', value: String(data.applications.length), inline: true })], components: [new ActionRowBuilder().addComponents(new ButtonBuilder().setCustomId('partner_back_dashboard').setLabel('رجوع').setStyle(ButtonStyle.Secondary))] }); + }}, + { name: 'partner_settings', async execute(interaction) { + const data = await getPartnerData(interaction.client, interaction.guildId); + const modal = new ModalBuilder().setCustomId('partner_settings_modal').setTitle('إعدادات الشراكات').addComponents( + new ActionRowBuilder().addComponents(new TextInputBuilder().setCustomId('min_members').setLabel('الحد الأدنى للأعضاء').setStyle(TextInputStyle.Short).setValue(String(data.requirements.minMembers)).setRequired(true)), + new ActionRowBuilder().addComponents(new TextInputBuilder().setCustomId('require_invite').setLabel('هل رابط الدعوة مطلوب؟ نعم/لا').setStyle(TextInputStyle.Short).setValue(data.requirements.requireInvite ? 'نعم' : 'لا').setRequired(true)), + new ActionRowBuilder().addComponents(new TextInputBuilder().setCustomId('require_active').setLabel('هل يشترط سيرفر نشط؟ نعم/لا').setStyle(TextInputStyle.Short).setValue(data.requirements.requireActive ? 'نعم' : 'لا').setRequired(true)), + ); + return interaction.showModal(modal); + }}, + { name: 'partner_back_dashboard', async execute(interaction) { + const data = await getPartnerData(interaction.client, interaction.guildId); + const pending = data.applications.filter(a => a.status === 'pending').length, active = data.partners.filter(p => p.status === 'active').length; + return interaction.update({ embeds: [new EmbedBuilder().setColor(0x5865f2).setTitle('🤝 إدارة الشراكات').setDescription('إدارة طلبات الشراكة والشراكات الحالية من هنا.').addFields({ name: 'الشراكات الحالية', value: String(active), inline: true }, { name: 'الطلبات المعلقة', value: String(pending), inline: true }, { name: 'إجمالي الطلبات', value: String(data.applications.length), inline: true })], components: [new ActionRowBuilder().addComponents(new ButtonBuilder().setCustomId('partner_pending').setLabel('الطلبات').setEmoji('🟡').setStyle(ButtonStyle.Primary), new ButtonBuilder().setCustomId('partner_active').setLabel('الشركاء').setEmoji('🤝').setStyle(ButtonStyle.Success), new ButtonBuilder().setCustomId('partner_stats').setLabel('الإحصائيات').setEmoji('📊').setStyle(ButtonStyle.Secondary), new ButtonBuilder().setCustomId('partner_settings').setLabel('الإعدادات').setEmoji('⚙️').setStyle(ButtonStyle.Secondary))] }); + }}, + { name: 'partner_accept', async execute(interaction, client, args) { return review(interaction, client, args[0], 'accepted'); } }, + { name: 'partner_reject', async execute(interaction, client, args) { return review(interaction, client, args[0], 'rejected'); } }, +]; + +async function review(interaction, client, id, status) { + if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) return interaction.reply({ content: '❌ تحتاج صلاحية إدارة السيرفر.', ephemeral: true }); + await interaction.deferUpdate(); + const data = await getPartnerData(client, interaction.guildId); + const app = data.applications.find(a => String(a.id) === String(id)); + if (!app || app.status !== 'pending') return interaction.followUp({ content: '❌ طلب الشراكة غير موجود أو تمت مراجعته مسبقًا.', ephemeral: true }); + app.status = status; app.reviewedBy = interaction.user.id; app.reviewedAt = new Date().toISOString(); + if (status === 'accepted') data.partners.push({ ...app, status: 'active', acceptedAt: app.reviewedAt }); + await savePartnerData(client, interaction.guildId, data); + const channel = interaction.guild.channels.cache.get(data.requestChannelId); + const message = channel ? await channel.messages.fetch(app.messageId).catch(() => null) : null; + if (message) await message.edit({ embeds: [applicationEmbed(app)], components: applicationButtons(app) }); + if (status === 'accepted' && data.announcementChannelId) { + const announcementChannel = interaction.guild.channels.cache.get(data.announcementChannelId); + if (announcementChannel?.isTextBased()) { + const text = `🤝 **شراكة جديدة**\n\n**${app.serverName}**\n\n${app.description}\n\n${app.invite}\n\n@everyone @here`; + await announcementChannel.send({ content: text, allowedMentions: { parse: ['everyone'] } }); + } + } + return interaction.followUp({ content: status === 'accepted' ? `✅ تم قبول طلب الشراكة #${app.id} ونشر الإعلان.` : `✅ تم رفض طلب الشراكة #${app.id}.`, ephemeral: true }); +} diff --git a/src/interactions/buttons/security/security.js b/src/interactions/buttons/security/security.js new file mode 100644 index 0000000000..50798cc5bd --- /dev/null +++ b/src/interactions/buttons/security/security.js @@ -0,0 +1,20 @@ +import securityDashboardOverrides from '../../../handlers/securityDashboardOverrides.js'; +import { securityDashboardButtonHandlers } from '../../../handlers/securityDashboardCore.js'; +import securityDashboardRuleHandlers from '../../../handlers/securityDashboardRuleHandlers.js'; +import securityAutoModDashboard from '../../../handlers/securityAutoModDashboard.js'; +import securityDashboardFixes from '../../../handlers/securityDashboardFixes.js'; +import securityFinalOverrides from './securityFinalOverrides.js'; +import securityStrikeCompatibility from './securityStrikeCompatibility.js'; + +// Keep the Security handlers in one deterministic registration point. +// The final override/compatibility handlers intentionally come last so legacy +// dashboard buttons cannot replace the current handlers. +export default [ + ...securityDashboardOverrides, + ...securityDashboardButtonHandlers, + ...securityDashboardRuleHandlers, + ...securityAutoModDashboard, + ...securityDashboardFixes, + ...securityFinalOverrides, + ...securityStrikeCompatibility, +]; diff --git a/src/interactions/buttons/security/securityBaseAliases.js b/src/interactions/buttons/security/securityBaseAliases.js new file mode 100644 index 0000000000..86bd804603 --- /dev/null +++ b/src/interactions/buttons/security/securityBaseAliases.js @@ -0,0 +1,3 @@ +// Legacy Security aliases were retired. +// The canonical handlers now live in securityDashboardCore.js and securityFinalOverrides.js. +export default []; diff --git a/src/interactions/buttons/security/securityFinalOverrides.js b/src/interactions/buttons/security/securityFinalOverrides.js new file mode 100644 index 0000000000..2ce09aed0a --- /dev/null +++ b/src/interactions/buttons/security/securityFinalOverrides.js @@ -0,0 +1,91 @@ +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, ModalBuilder, TextInputBuilder, TextInputStyle } from 'discord.js'; +import { getSecurityConfig, updateSecurityConfig, getStrikes, clearStrikes } from '../../../services/security/securityService.js'; + +const ok = interaction => interaction.customId.split(':').at(-1) === interaction.user.id; +const deny = interaction => interaction.reply({ content: 'This security dashboard belongs to another moderator.', ephemeral: true }); +const button = (id, label, style = ButtonStyle.Secondary) => new ButtonBuilder().setCustomId(id).setLabel(label).setStyle(style); +const row = (...buttons) => new ActionRowBuilder().addComponents(buttons); +const AUTOMOD_ACTIONS = ['delete', 'timeout', 'kick', 'ban']; + +function embed(title, description, guild, color = 0xfee75c) { + return new EmbedBuilder().setAuthor({ name: 'Infinity Security Center', iconURL: guild.iconURL({ size: 128 }) || undefined }).setTitle(title).setDescription(description).setColor(color).setFooter({ text: 'Infinity System • Changes save automatically' }).setTimestamp(); +} + +async function renderStrikes(interaction, client) { + const members = await interaction.guild.members.fetch().catch(() => interaction.guild.members.cache); + const entries = []; + for (const member of members.values()) { + if (member.user.bot) continue; + const strike = await getStrikes(client, interaction.guildId, member.id).catch(() => ({ count: 0 })); + const count = Number(strike?.count || 0); + if (count > 0) entries.push({ id: member.id, name: member.displayName || member.user.username, count }); + } + entries.sort((a, b) => b.count - a.count); + const text = entries.slice(0, 10).map((entry, index) => `${index + 1}. <@${entry.id}> — **${entry.count}** strikes`).join('\n') || 'No active strikes.'; + const components = [row(button(`security_back2:${interaction.user.id}`, '← Back'), button(`strikes_refresh2:${interaction.user.id}`, '🔄 Refresh', ButtonStyle.Success))]; + for (let i = 0; i < Math.min(entries.length, 8); i += 4) { + components.push(row(...entries.slice(i, i + 4).map(entry => button(`strike_manage2:${entry.id}:${interaction.user.id}`, entry.name.slice(0, 80), ButtonStyle.Primary)))); + } + return interaction.update({ embeds: [embed('🏆 Strikes', `**${interaction.guild.name}**\n\n${text}\n\nSelect a member to manage their strikes.`, interaction.guild)], components }); +} + +async function manageStrike(interaction, client, userId) { + const strike = await getStrikes(client, interaction.guildId, userId).catch(() => ({ count: 0, lastReason: '' })); + return interaction.update({ embeds: [embed('🏆 Strike Management', `<@${userId}>\n\n**Strikes:** ${Number(strike?.count || 0)}\n**Last Reason:** ${strike?.lastReason || '—'}`, interaction.guild)], components: [row(button(`strike_reset2:${userId}:${interaction.user.id}`, '🧹 Remove Strikes', ButtonStyle.Danger), button(`strikes_back2:${interaction.user.id}`, '← Back'))] }); +} + +async function resetStrike(interaction, client, userId) { + await clearStrikes(client, interaction.guildId, userId); + return renderStrikes(interaction, client); +} + +function parseIds(value) { + return [...new Set(String(value || '').split(/[\s,\n]+/).map(value => value.match(/\d{15,25}/)?.[0]).filter(Boolean))].slice(0, 100); +} + +async function whitelistPage(interaction, client) { + const config = await getSecurityConfig(client, interaction.guildId); + return interaction.update({ embeds: [embed('👤 Whitelist', `Accounts listed here bypass security actions.\n\n**Users:** ${config.whitelist.users.length}\n**Roles:** ${config.whitelist.roles.length}\n**Bots:** ${config.whitelist.bots.length}`, interaction.guild, 0x57f287)], components: [row(button(`security_back2:${interaction.user.id}`, '← Back'), button(`wl_users2:${interaction.user.id}`, '👤 Users', ButtonStyle.Primary), button(`wl_roles2:${interaction.user.id}`, '🎭 Roles', ButtonStyle.Primary), button(`wl_bots2:${interaction.user.id}`, '🤖 Bots', ButtonStyle.Primary))] }); +} + +function whitelistModal(interaction, type, title, label, current) { + return interaction.showModal(new ModalBuilder().setCustomId(`security_final_wl_${type}:${interaction.user.id}`).setTitle(title).addComponents(new ActionRowBuilder().addComponents(new TextInputBuilder().setCustomId('value').setLabel(label).setPlaceholder('ID or mention, multiple values supported').setStyle(TextInputStyle.Paragraph).setRequired(false).setValue(current.join('\n').slice(0, 4000))))); +} + +async function saveWhitelist(interaction, client, type) { + const ids = parseIds(interaction.fields.getTextInputValue('value')); + await updateSecurityConfig(client, interaction.guildId, { whitelist: { [type]: ids } }); + return whitelistPage(interaction, client); +} + +function parseAutoModKey(interaction) { return interaction.customId.split(':').at(-2); } + +async function autoModPunishment(interaction, client) { + if (!ok(interaction)) return deny(interaction); + const key = parseAutoModKey(interaction); + const config = await getSecurityConfig(client, interaction.guildId); + if (!config.autoMod[key]) return interaction.reply({ content: 'AutoMod rule unavailable.', ephemeral: true }); + const current = config.autoMod[key].punishment; + const index = AUTOMOD_ACTIONS.indexOf(current); + const next = AUTOMOD_ACTIONS[(index < 0 ? 0 : index + 1) % AUTOMOD_ACTIONS.length]; + await updateSecurityConfig(client, interaction.guildId, { autoMod: { [key]: { punishment: next } } }); + return interaction.update({ embeds: [embed(`🤖 ${key} Settings`, `**Status:** ${config.autoMod[key].enabled ? '🟢 ACTIVE' : '🔴 OFF'}\n**Punishment:** **${next}**`, interaction.guild)], components: [row(button(`automod_back:${interaction.user.id}`, '← AutoMod'), button(`automod_toggle:${key}:${interaction.user.id}`, config.autoMod[key].enabled ? '🔴 Disable' : '🟢 Enable', config.autoMod[key].enabled ? ButtonStyle.Danger : ButtonStyle.Success), button(`automod_punishment:${key}:${interaction.user.id}`, `⚖️ ${next}`, ButtonStyle.Primary))] }); +} + +export default [ + { name: 'security_panel_strikes2', execute: async (i, c) => ok(i) ? renderStrikes(i, c) : deny(i) }, + { name: 'security_panel_strikes', execute: async (i, c) => ok(i) ? renderStrikes(i, c) : deny(i) }, + { name: 'strikes_refresh2', execute: async (i, c) => ok(i) ? renderStrikes(i, c) : deny(i) }, + { name: 'strike_manage2', execute: async (i, c, args) => ok(i) ? manageStrike(i, c, args[0]) : deny(i) }, + { name: 'strike_reset2', execute: async (i, c, args) => ok(i) ? resetStrike(i, c, args[0]) : deny(i) }, + { name: 'strikes_back2', execute: async (i, c) => ok(i) ? renderStrikes(i, c) : deny(i) }, + { name: 'security_panel_whitelist2', execute: async (i, c) => ok(i) ? whitelistPage(i, c) : deny(i) }, + { name: 'security_panel_whitelist', execute: async (i, c) => ok(i) ? whitelistPage(i, c) : deny(i) }, + { name: 'wl_users2', execute: async (i, c) => { if (!ok(i)) return deny(i); const x = await getSecurityConfig(c, i.guildId); return whitelistModal(i, 'users', 'Whitelist Users', 'User IDs', x.whitelist.users); } }, + { name: 'wl_roles2', execute: async (i, c) => { if (!ok(i)) return deny(i); const x = await getSecurityConfig(c, i.guildId); return whitelistModal(i, 'roles', 'Whitelist Roles', 'Role IDs', x.whitelist.roles); } }, + { name: 'wl_bots2', execute: async (i, c) => { if (!ok(i)) return deny(i); const x = await getSecurityConfig(c, i.guildId); return whitelistModal(i, 'bots', 'Whitelist Bots', 'Bot IDs', x.whitelist.bots); } }, + { name: 'security_final_wl_users', execute: async (i, c) => ok(i) ? saveWhitelist(i, c, 'users') : deny(i) }, + { name: 'security_final_wl_roles', execute: async (i, c) => ok(i) ? saveWhitelist(i, c, 'roles') : deny(i) }, + { name: 'security_final_wl_bots', execute: async (i, c) => ok(i) ? saveWhitelist(i, c, 'bots') : deny(i) }, + { name: 'automod_punishment', execute: autoModPunishment }, +]; diff --git a/src/interactions/buttons/security/securityStrikeCompatibility.js b/src/interactions/buttons/security/securityStrikeCompatibility.js new file mode 100644 index 0000000000..2b472eeb80 --- /dev/null +++ b/src/interactions/buttons/security/securityStrikeCompatibility.js @@ -0,0 +1,3 @@ +// Legacy strike compatibility handlers retired. +// Strikes are handled by securityFinalOverrides.js. +export default []; diff --git a/src/interactions/buttons/staff.js b/src/interactions/buttons/staff.js new file mode 100644 index 0000000000..e6900139cd --- /dev/null +++ b/src/interactions/buttons/staff.js @@ -0,0 +1,112 @@ +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from 'discord.js'; +import { calculateActivityScore, countWarnings, getStaffData, getStaffProfile } from '../../services/staffService.js'; + +function nav() { + return [ + new ActionRowBuilder().addComponents( + new ButtonBuilder().setCustomId('staff_my_profile').setLabel('My Profile').setStyle(ButtonStyle.Primary), + new ButtonBuilder().setCustomId('staff_activity').setLabel('Activity').setStyle(ButtonStyle.Secondary), + new ButtonBuilder().setCustomId('staff_list').setLabel('Staff List').setStyle(ButtonStyle.Secondary), + ), + new ActionRowBuilder().addComponents( + new ButtonBuilder().setCustomId('staff_warnings').setLabel('Warnings').setStyle(ButtonStyle.Secondary), + new ButtonBuilder().setCustomId('staff_promotions').setLabel('Promotions').setStyle(ButtonStyle.Secondary), + new ButtonBuilder().setCustomId('staff_demotions').setLabel('Demotions').setStyle(ButtonStyle.Secondary), + new ButtonBuilder().setCustomId('staff_notes').setLabel('Notes').setStyle(ButtonStyle.Secondary), + ), + ]; +} + +function back() { + return [new ActionRowBuilder().addComponents(new ButtonBuilder().setCustomId('staff_home').setLabel('Back').setStyle(ButtonStyle.Secondary))]; +} + +async function render(interaction, embed) { + return interaction.update({ embeds: [embed], components: back() }); +} + +export default [ + { + name: 'staff_home', + async execute(interaction) { + const data = await getStaffData(interaction.guildId); + const warned = Object.values(data.members).filter((m) => countWarnings(m) > 0).length; + await interaction.update({ + embeds: [new EmbedBuilder().setTitle('Staff Management').setDescription(`**${interaction.guild.name}**\nCentralized staff management, activity and history.`).addFields( + { name: 'Staff', value: `**${Object.keys(data.members).length}**`, inline: true }, + { name: 'With Warnings', value: `**${warned}**`, inline: true }, + { name: 'Review Threshold', value: `**${data.config.warningsBeforeReview}** warnings`, inline: true }, + )], + components: nav(), + }); + }, + }, + { + name: 'staff_my_profile', + async execute(interaction) { + const profile = await getStaffProfile(interaction.guildId, interaction.user.id); + await render(interaction, new EmbedBuilder().setTitle('Staff Profile').setDescription(`${interaction.user}`).addFields( + { name: 'Activity', value: `**${calculateActivityScore(profile)}%**`, inline: true }, + { name: 'Warnings', value: `**${countWarnings(profile)}**`, inline: true }, + { name: 'Moderation Actions', value: `**${profile.activity?.moderationActions || 0}**`, inline: true }, + { name: 'Tickets Handled', value: `**${profile.activity?.ticketsHandled || 0}**`, inline: true }, + { name: 'Promotions', value: `**${profile.promotions.length}**`, inline: true }, + { name: 'Demotions', value: `**${profile.demotions.length}**`, inline: true }, + ).setThumbnail(interaction.user.displayAvatarURL())); + }, + }, + { + name: 'staff_activity', + async execute(interaction) { + const data = await getStaffData(interaction.guildId); + const rows = Object.entries(data.members).map(([id, profile]) => ({ id, score: calculateActivityScore(profile) })).sort((a, b) => b.score - a.score).slice(0, 10); + const description = rows.length ? rows.map((row, i) => `${i + 1}. <@${row.id}> — **${row.score}%**`).join('\n') : 'No staff activity has been recorded yet.'; + await render(interaction, new EmbedBuilder().setTitle('Staff Activity').setDescription(description)); + }, + }, + { + name: 'staff_list', + async execute(interaction) { + const data = await getStaffData(interaction.guildId); + const entries = Object.entries(data.members).slice(0, 20); + const description = entries.length ? entries.map(([id, profile]) => `<@${id}> — **${calculateActivityScore(profile)}%** activity • **${countWarnings(profile)}** warnings`).join('\n') : 'No staff profiles have been created yet.'; + await render(interaction, new EmbedBuilder().setTitle('Staff List').setDescription(description)); + }, + }, + { + name: 'staff_warnings', + async execute(interaction) { + const data = await getStaffData(interaction.guildId); + const entries = Object.entries(data.members).filter(([, p]) => countWarnings(p) > 0).slice(0, 10); + const description = entries.length ? entries.map(([id, p]) => `<@${id}> — **${countWarnings(p)}** warnings`).join('\n') : 'No staff warnings.'; + await render(interaction, new EmbedBuilder().setTitle('Staff Warnings').setDescription(description)); + }, + }, + { + name: 'staff_promotions', + async execute(interaction) { + const data = await getStaffData(interaction.guildId); + const records = Object.entries(data.members).flatMap(([id, p]) => (p.promotions || []).map((record) => ({ id, ...record }))).sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt))).slice(0, 10); + const description = records.length ? records.map((r) => `<@${r.id}> — **${r.fromRoleName}** → **${r.toRoleName}**\n${r.reason}`).join('\n\n') : 'No promotions recorded.'; + await render(interaction, new EmbedBuilder().setTitle('Staff Promotions').setDescription(description)); + }, + }, + { + name: 'staff_demotions', + async execute(interaction) { + const data = await getStaffData(interaction.guildId); + const records = Object.entries(data.members).flatMap(([id, p]) => (p.demotions || []).map((record) => ({ id, ...record }))).sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt))).slice(0, 10); + const description = records.length ? records.map((r) => `<@${r.id}> — **${r.fromRoleName}** → **${r.toRoleName}**\n${r.reason}`).join('\n\n') : 'No demotions recorded.'; + await render(interaction, new EmbedBuilder().setTitle('Staff Demotions').setDescription(description)); + }, + }, + { + name: 'staff_notes', + async execute(interaction) { + const data = await getStaffData(interaction.guildId); + const records = Object.entries(data.members).flatMap(([id, p]) => (p.notes || []).map((record) => ({ id, ...record }))).sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt))).slice(0, 10); + const description = records.length ? records.map((r) => `<@${r.id}> — ${r.note}`).join('\n') : 'No staff notes.'; + await render(interaction, new EmbedBuilder().setTitle('Staff Notes').setDescription(description)); + }, + }, +]; diff --git a/src/interactions/buttons/suggestions.js b/src/interactions/buttons/suggestions.js new file mode 100644 index 0000000000..57dd9fddf9 --- /dev/null +++ b/src/interactions/buttons/suggestions.js @@ -0,0 +1,53 @@ +import { ModalBuilder, ActionRowBuilder, TextInputBuilder, TextInputStyle, PermissionFlagsBits } from 'discord.js'; +import { getSuggestions, saveSuggestions, suggestionEmbed, suggestionButtons } from '../../utils/suggestions.js'; + +const submitModal = new ModalBuilder().setCustomId('suggestions_submit_modal').setTitle('Submit Suggestion').addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder().setCustomId('suggestion').setLabel('Your suggestion').setPlaceholder('Describe your idea...').setStyle(TextInputStyle.Paragraph).setRequired(true).setMaxLength(1500), + ), +); + +async function getSuggestion(interaction, id) { + const data = await getSuggestions(interaction.client, interaction.guildId); + const suggestion = data.items?.find(item => String(item.id) === String(id)); + return { data, suggestion }; +} + +async function refresh(interaction, suggestion) { + const message = await interaction.channel.messages.fetch(suggestion.messageId).catch(() => null); + if (message) await message.edit({ embeds: [suggestionEmbed(suggestion)], components: suggestionButtons(suggestion) }); +} + +async function vote(interaction, client, args, direction) { + await interaction.deferUpdate(); + const { data, suggestion } = await getSuggestion(interaction, args[0]); + if (!suggestion) return interaction.followUp({ content: '❌ Suggestion not found.', ephemeral: true }); + if (suggestion.authorId === interaction.user.id) return interaction.followUp({ content: '❌ You cannot vote on your own suggestion.', ephemeral: true }); + suggestion.upvotes = Array.isArray(suggestion.upvotes) ? suggestion.upvotes : []; + suggestion.downvotes = Array.isArray(suggestion.downvotes) ? suggestion.downvotes : []; + if (direction === 'up') { + suggestion.downvotes = suggestion.downvotes.filter(id => id !== interaction.user.id); + suggestion.upvotes = suggestion.upvotes.includes(interaction.user.id) ? suggestion.upvotes.filter(id => id !== interaction.user.id) : [...suggestion.upvotes, interaction.user.id]; + } else { + suggestion.upvotes = suggestion.upvotes.filter(id => id !== interaction.user.id); + suggestion.downvotes = suggestion.downvotes.includes(interaction.user.id) ? suggestion.downvotes.filter(id => id !== interaction.user.id) : [...suggestion.downvotes, interaction.user.id]; + } + await saveSuggestions(client, interaction.guildId, data); + await refresh(interaction, suggestion); +} + +export default [ + { name: 'suggestions_submit', async execute(interaction) { await interaction.showModal(submitModal); } }, + { name: 'suggestions_up', async execute(interaction, client, args) { await vote(interaction, client, args, 'up'); } }, + { name: 'suggestions_down', async execute(interaction, client, args) { await vote(interaction, client, args, 'down'); } }, + ...['accept', 'reject'].map(action => ({ name: `suggestions_${action}`, async execute(interaction, client, args) { + await interaction.deferUpdate(); + if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) return interaction.followUp({ content: '❌ You need Manage Server permission.', ephemeral: true }); + const { data, suggestion } = await getSuggestion(interaction, args[0]); + if (!suggestion) return interaction.followUp({ content: '❌ Suggestion not found.', ephemeral: true }); + suggestion.status = action === 'accept' ? 'accepted' : 'rejected'; + suggestion.moderatorId = interaction.user.id; + await saveSuggestions(client, interaction.guildId, data); + await refresh(interaction, suggestion); + } })), +]; diff --git a/src/interactions/buttons/tempvoice.js b/src/interactions/buttons/tempvoice.js new file mode 100644 index 0000000000..4f019fc5bb --- /dev/null +++ b/src/interactions/buttons/tempvoice.js @@ -0,0 +1,54 @@ +import { ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder } from 'discord.js'; +import { getJoinToCreateConfig, saveJoinToCreateConfig, getTemporaryChannelInfo, unregisterTemporaryChannel } from '../../utils/database.js'; + +async function getOwnedRoom(interaction, client) { + const channel = interaction.member?.voice?.channel; + if (!channel) return { error: '❌ Join your temporary voice room first.' }; + const info = await getTemporaryChannelInfo(client, interaction.guildId, channel.id); + if (!info) return { error: '❌ You are not inside a temporary voice room.' }; + if (info.ownerId !== interaction.user.id) return { error: '❌ Only the room owner can use this panel.' }; + return { channel, info }; +} + +function modal(id, title, label, placeholder) { + return new ModalBuilder().setCustomId(id).setTitle(title).addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder().setCustomId('value').setLabel(label).setPlaceholder(placeholder).setStyle(TextInputStyle.Short).setRequired(true).setMaxLength(100), + ), + ); +} + +const handlers = { + tempvoice_lock: async (i, c) => { + const room = await getOwnedRoom(i, c); if (room.error) return i.reply({ content: room.error, ephemeral: true }); + const config = await getJoinToCreateConfig(c, i.guildId); const info = config.temporaryChannels[room.channel.id]; + const locked = !info.locked; + info.locked = locked; + await room.channel.permissionOverwrites.edit(i.guildId, { Connect: !locked }); + await saveJoinToCreateConfig(c, i.guildId, config); + return i.reply({ content: locked ? '🔒 Room locked.' : '🔓 Room unlocked.', ephemeral: true }); + }, + tempvoice_hide: async (i, c) => { + const room = await getOwnedRoom(i, c); if (room.error) return i.reply({ content: room.error, ephemeral: true }); + const config = await getJoinToCreateConfig(c, i.guildId); const info = config.temporaryChannels[room.channel.id]; + const hidden = !info.hidden; + info.hidden = hidden; + await room.channel.permissionOverwrites.edit(i.guildId, { ViewChannel: !hidden }); + await room.channel.permissionOverwrites.edit(i.user.id, { ViewChannel: true, Connect: true, Speak: true, MoveMembers: true, ManageChannels: true }); + await saveJoinToCreateConfig(c, i.guildId, config); + return i.reply({ content: hidden ? '👁️ Room hidden.' : '👁️ Room visible.', ephemeral: true }); + }, + tempvoice_rename: async i => i.showModal(modal('tempvoice_rename_modal', 'Rename Room', 'New room name', "Abdallah's Room")), + tempvoice_limit: async i => i.showModal(modal('tempvoice_limit_modal', 'User Limit', 'Maximum users (0 = unlimited)', '0')), + tempvoice_kick: async i => i.showModal(modal('tempvoice_kick_modal', 'Kick User', 'User ID', '123456789012345678')), + tempvoice_mute: async i => i.showModal(modal('tempvoice_mute_modal', 'Mute User', 'User ID', '123456789012345678')), + tempvoice_transfer: async i => i.showModal(modal('tempvoice_transfer_modal', 'Transfer Ownership', 'New owner User ID', '123456789012345678')), + tempvoice_delete: async (i, c) => { + const room = await getOwnedRoom(i, c); if (room.error) return i.reply({ content: room.error, ephemeral: true }); + await unregisterTemporaryChannel(c, i.guildId, room.channel.id); + await room.channel.delete('TempVoice owner deleted room').catch(() => {}); + return i.reply({ content: '🗑️ Room deleted.', ephemeral: true }); + }, +}; + +export default Object.entries(handlers).map(([name, execute]) => ({ name, execute })); diff --git a/src/interactions/modals/partnerApply.js b/src/interactions/modals/partnerApply.js new file mode 100644 index 0000000000..b52e75f9df --- /dev/null +++ b/src/interactions/modals/partnerApply.js @@ -0,0 +1,28 @@ +import { getPartnerData, savePartnerData, applicationEmbed, applicationButtons } from '../../utils/partner.js'; + +export default { + name: 'partner_apply_modal', + async execute(interaction, client) { + const server = interaction.fields.getTextInputValue('server_name').trim(); + const invite = interaction.fields.getTextInputValue('invite').trim(); + const membersRaw = interaction.fields.getTextInputValue('members').trim(); + const description = interaction.fields.getTextInputValue('description').trim(); + const members = Number(membersRaw.replace(/[^0-9]/g, '')); + if (!server || !description || !Number.isFinite(members)) return interaction.reply({ content: '❌ تأكد من تعبئة جميع بيانات الطلب بشكل صحيح.', ephemeral: true }); + const data = await getPartnerData(client, interaction.guildId); + if (data.requirements.requireInvite && !/^https?:\/\/discord(?:\.gg|\.com\/invite)\//i.test(invite)) return interaction.reply({ content: '❌ أرسل رابط دعوة صالح لسيرفرك.', ephemeral: true }); + if (members < data.requirements.minMembers) return interaction.reply({ content: `❌ يجب أن يحتوي سيرفرك على ${data.requirements.minMembers} عضوًا على الأقل.`, ephemeral: true }); + const duplicate = data.applications.find(a => a.applicantId === interaction.user.id && a.status === 'pending'); + if (duplicate) return interaction.reply({ content: `❌ لديك طلب شراكة معلق بالفعل (#${duplicate.id}).`, ephemeral: true }); + + data.counter += 1; + const app = { id: data.counter, serverName: server, invite, members, description, applicantId: interaction.user.id, status: 'pending', createdAt: new Date().toISOString(), messageId: null }; + const target = interaction.guild.channels.cache.get(data.requestChannelId); + if (!target) return interaction.reply({ content: '❌ روم طلبات الشراكة غير موجود. شغّل `/partner setup` مرة أخرى.', ephemeral: true }); + const message = await target.send({ embeds: [applicationEmbed(app)], components: applicationButtons(app) }); + app.messageId = message.id; + data.applications.push(app); + await savePartnerData(client, interaction.guildId, data); + return interaction.reply({ content: `✅ تم إرسال طلب الشراكة #${app.id} بنجاح.`, ephemeral: true }); + }, +}; diff --git a/src/interactions/modals/partnerSettings.js b/src/interactions/modals/partnerSettings.js new file mode 100644 index 0000000000..652b670a86 --- /dev/null +++ b/src/interactions/modals/partnerSettings.js @@ -0,0 +1,17 @@ +import { getPartnerData, savePartnerData } from '../../utils/partner.js'; + +export default { + name: 'partner_settings_modal', + async execute(interaction, client) { + const minMembers = Number(interaction.fields.getTextInputValue('min_members').replace(/[^0-9]/g, '')); + const requireInvite = interaction.fields.getTextInputValue('require_invite').trim().toLowerCase() === 'yes'; + const requireActive = interaction.fields.getTextInputValue('require_active').trim().toLowerCase() === 'yes'; + if (!Number.isFinite(minMembers) || minMembers < 0) return interaction.reply({ content: '❌ Minimum members must be a valid number.', ephemeral: true }); + const data = await getPartnerData(client, interaction.guildId); + data.requirements.minMembers = minMembers; + data.requirements.requireInvite = requireInvite; + data.requirements.requireActive = requireActive; + await savePartnerData(client, interaction.guildId, data); + return interaction.reply({ content: `✅ Partnership settings updated. Minimum members: ${minMembers}.`, ephemeral: true }); + }, +}; diff --git a/src/interactions/modals/security/security.js b/src/interactions/modals/security/security.js new file mode 100644 index 0000000000..e18b8a0c80 --- /dev/null +++ b/src/interactions/modals/security/security.js @@ -0,0 +1,42 @@ +import { securityModalHandlers } from '../../../handlers/securityHandlers.js'; +import { securityAdvancedModalHandlers } from '../../../handlers/securityAdvancedHandlers.js'; +import { securityDashboardModalHandlers } from '../../../handlers/securityDashboardHandlers.js'; +import securityDashboardFixes from '../../../handlers/securityDashboardFixes.js'; +import { getSecurityConfig, updateSecurityConfig } from '../../../services/security/securityService.js'; + +const ok = i => i.customId.split(':').at(-1) === i.user.id; +const deny = i => i.reply({ content: 'This security dashboard belongs to another moderator.', ephemeral: true }); + +function parseIds(value) { + return [...new Set(String(value || '').split(/[\s,\n]+/).map(part => part.match(/\d{15,25}/)?.[0]).filter(Boolean))].slice(0, 100); +} + +async function saveWhitelist(interaction, client, type) { + if (!ok(interaction)) return deny(interaction); + const ids = parseIds(interaction.fields.getTextInputValue('value')); + const config = await getSecurityConfig(client, interaction.guildId); + const whitelist = { + users: Array.isArray(config.whitelist?.users) ? config.whitelist.users.map(String) : [], + roles: Array.isArray(config.whitelist?.roles) ? config.whitelist.roles.map(String) : [], + bots: Array.isArray(config.whitelist?.bots) ? config.whitelist.bots.map(String) : [], + }; + whitelist[type] = ids; + await updateSecurityConfig(client, interaction.guildId, { whitelist }); + return interaction.reply({ content: `Whitelist updated successfully. **${ids.length}** ${type} entr${ids.length === 1 ? 'y' : 'ies'} saved.`, ephemeral: true }); +} + +export default [ + ...securityModalHandlers, + ...securityAdvancedModalHandlers, + ...securityDashboardModalHandlers, + ...securityDashboardFixes.filter(h => h.name.endsWith('_modal')), + { name: 'security_final_wl_users', execute: (i, c) => saveWhitelist(i, c, 'users') }, + { name: 'security_final_wl_roles', execute: (i, c) => saveWhitelist(i, c, 'roles') }, + { name: 'security_final_wl_bots', execute: (i, c) => saveWhitelist(i, c, 'bots') }, + { name: 'security_whitelist_users_modal', execute: (i, c) => saveWhitelist(i, c, 'users') }, + { name: 'security_whitelist_roles_modal', execute: (i, c) => saveWhitelist(i, c, 'roles') }, + { name: 'security_whitelist_bots_modal', execute: (i, c) => saveWhitelist(i, c, 'bots') }, + { name: 'security_whitelist_user_modal', execute: (i, c) => saveWhitelist(i, c, 'users') }, + { name: 'security_whitelist_role_modal', execute: (i, c) => saveWhitelist(i, c, 'roles') }, + { name: 'security_whitelist_bot_modal', execute: (i, c) => saveWhitelist(i, c, 'bots') }, +]; \ No newline at end of file diff --git a/src/interactions/modals/suggestions.js b/src/interactions/modals/suggestions.js new file mode 100644 index 0000000000..1d826bae38 --- /dev/null +++ b/src/interactions/modals/suggestions.js @@ -0,0 +1,34 @@ +import { getSuggestions, saveSuggestions, nextSuggestionId, suggestionEmbed, suggestionButtons } from '../../utils/suggestions.js'; + +export default { + name: 'suggestions_submit_modal', + async execute(interaction, client) { + const text = interaction.fields.getTextInputValue('suggestion').trim(); + if (!text) return interaction.reply({ content: '❌ Suggestion cannot be empty.', ephemeral: true }); + + const data = await getSuggestions(client, interaction.guildId); + const id = await nextSuggestionId(client, interaction.guildId); + data.counter = Math.max(Number(data.counter || 0), id); + const channel = interaction.guild.channels.cache.get(data.channelId); + if (!channel) return interaction.reply({ content: '❌ Suggestions channel is missing. Run `/suggestions setup` again.', ephemeral: true }); + + const suggestion = { + id, + authorId: interaction.user.id, + text, + status: 'pending', + upvotes: [], + downvotes: [], + createdAt: new Date().toISOString(), + messageId: null, + }; + + const message = await channel.send({ embeds: [suggestionEmbed(suggestion)], components: suggestionButtons(suggestion) }); + suggestion.messageId = message.id; + data.items = Array.isArray(data.items) ? data.items : []; + data.items.push(suggestion); + await saveSuggestions(client, interaction.guildId, data); + + return interaction.reply({ content: `✅ Suggestion #${id} submitted.`, ephemeral: true }); + }, +}; diff --git a/src/interactions/modals/tempvoice.js b/src/interactions/modals/tempvoice.js new file mode 100644 index 0000000000..a0c3e26595 --- /dev/null +++ b/src/interactions/modals/tempvoice.js @@ -0,0 +1,74 @@ +import { getJoinToCreateConfig, saveJoinToCreateConfig, getTemporaryChannelInfo } from '../../utils/database.js'; + +async function roomFor(interaction, client) { + const channel = interaction.member?.voice?.channel; + if (!channel) return { error: '❌ Join your temporary voice room first.' }; + const info = await getTemporaryChannelInfo(client, interaction.guildId, channel.id); + if (!info || info.ownerId !== interaction.user.id) return { error: '❌ Only the owner of a temporary room can use this panel.' }; + return { channel, info }; +} + +export default [ + { + name: 'tempvoice_rename_modal', + async execute(i, client) { + const room = await roomFor(i, client); if (room.error) return i.reply({ content: room.error, ephemeral: true }); + const name = i.fields.getTextInputValue('value').trim().replace(/[\r\n\t]/g, ' ').slice(0, 100); + if (!name) return i.reply({ content: '❌ Enter a valid room name.', ephemeral: true }); + await room.channel.setName(name); + return i.reply({ content: `✏️ Room renamed to **${name}**.`, ephemeral: true }); + }, + }, + { + name: 'tempvoice_limit_modal', + async execute(i, client) { + const room = await roomFor(i, client); if (room.error) return i.reply({ content: room.error, ephemeral: true }); + const limit = Number(i.fields.getTextInputValue('value')); + if (!Number.isInteger(limit) || limit < 0 || limit > 99) return i.reply({ content: '❌ Limit must be between 0 and 99.', ephemeral: true }); + await room.channel.setUserLimit(limit); + return i.reply({ content: `👥 User limit set to **${limit === 0 ? 'Unlimited' : limit}**.`, ephemeral: true }); + }, + }, + { + name: 'tempvoice_kick_modal', + async execute(i, client) { + const room = await roomFor(i, client); if (room.error) return i.reply({ content: room.error, ephemeral: true }); + const id = i.fields.getTextInputValue('value').trim(); + const member = await i.guild.members.fetch(id).catch(() => null); + if (!member || member.voice.channelId !== room.channel.id) return i.reply({ content: '❌ That user is not in your room.', ephemeral: true }); + await member.voice.disconnect('TempVoice owner kicked user'); + return i.reply({ content: `🚫 Kicked <@${member.id}> from the room.`, ephemeral: true }); + }, + }, + { + name: 'tempvoice_mute_modal', + async execute(i, client) { + const room = await roomFor(i, client); if (room.error) return i.reply({ content: room.error, ephemeral: true }); + const id = i.fields.getTextInputValue('value').trim(); + const member = await i.guild.members.fetch(id).catch(() => null); + if (!member || member.voice.channelId !== room.channel.id) return i.reply({ content: '❌ That user is not in your room.', ephemeral: true }); + const nextMuted = !member.voice.serverMute; + await member.voice.setMute(nextMuted, 'TempVoice owner toggled mute'); + return i.reply({ content: nextMuted ? `🔇 Muted <@${member.id}>.` : `🔊 Unmuted <@${member.id}>.`, ephemeral: true }); + }, + }, + { + name: 'tempvoice_transfer_modal', + async execute(i, client) { + const room = await roomFor(i, client); if (room.error) return i.reply({ content: room.error, ephemeral: true }); + const id = i.fields.getTextInputValue('value').trim(); + const member = await i.guild.members.fetch(id).catch(() => null); + if (!member || member.voice.channelId !== room.channel.id) return i.reply({ content: '❌ The new owner must be inside your room.', ephemeral: true }); + const config = await getJoinToCreateConfig(client, i.guildId); + const info = config.temporaryChannels[room.channel.id]; + const oldOwnerId = info.ownerId; + info.ownerId = member.id; + await saveJoinToCreateConfig(client, i.guildId, config); + await room.channel.permissionOverwrites.edit(member.id, { Connect: true, Speak: true, MoveMembers: true, ManageChannels: true }).catch(() => {}); + if (oldOwnerId !== member.id) { + await room.channel.permissionOverwrites.edit(oldOwnerId, { Connect: true, Speak: true, MoveMembers: false, ManageChannels: false }).catch(() => {}); + } + return i.reply({ content: `👑 Ownership transferred to <@${member.id}>.`, ephemeral: true }); + }, + }, +]; diff --git a/src/services/birthdayService.js b/src/services/birthdayService.js deleted file mode 100644 index 002665d4eb..0000000000 --- a/src/services/birthdayService.js +++ /dev/null @@ -1,359 +0,0 @@ -// birthdayService.js - -import { getGuildConfig } from './config/guildConfig.js'; -import { getGuildBirthdays, setBirthday as dbSetBirthday, deleteBirthday as dbDeleteBirthday, getMonthName, getBirthdayTrackingKey } from '../utils/database.js'; -import { logger } from '../utils/logger.js'; -import { TitanBotError, ErrorTypes } from '../utils/errorHandler.js'; - -export function validateBirthday(month, day) { - - if (typeof month !== 'number' || typeof day !== 'number') { - return { - isValid: false, - error: 'Month and day must be numbers' - }; - } - - if (month < 1 || month > 12) { - return { - isValid: false, - error: 'Month must be between 1 and 12' - }; - } - - if (day < 1 || day > 31) { - return { - isValid: false, - error: 'Day must be between 1 and 31' - }; - } - - const currentYear = new Date().getFullYear(); - const date = new Date(currentYear, month - 1, day); - - if (isNaN(date.getTime()) || date.getMonth() !== month - 1 || date.getDate() !== day) { - return { - isValid: false, - error: 'Invalid date. Please check the month and day combination (e.g., February 29th only exists in leap years)' - }; - } - - return { isValid: true }; -} - -export async function setBirthday(client, guildId, userId, month, day) { - try { - - const validation = validateBirthday(month, day); - if (!validation.isValid) { - logger.warn('Birthday validation failed', { - userId, - guildId, - month, - day, - error: validation.error - }); - - throw new TitanBotError( - validation.error, - ErrorTypes.VALIDATION, - validation.error, - { month, day, userId, guildId } - ); - } - - const success = await dbSetBirthday(client, guildId, userId, month, day); - - if (!success) { - throw new TitanBotError( - 'Failed to save birthday to database', - ErrorTypes.DATABASE, - 'Failed to set your birthday. Please try again later.', - { userId, guildId, month, day } - ); - } - - logger.info('Birthday set successfully', { - userId, - guildId, - month, - day, - monthName: getMonthName(month) - }); - - return { - data: { - month, - day, - monthName: getMonthName(month) - } - }; - } catch (error) { - logger.error('Error in setBirthday service', { - error: error.message, - stack: error.stack, - userId, - guildId, - month, - day - }); - - throw error; - } -} - -export async function getUserBirthday(client, guildId, userId) { - try { - const birthdays = await getGuildBirthdays(client, guildId); - const birthdayData = birthdays[userId]; - - if (!birthdayData) { - return null; - } - - return { - month: birthdayData.month, - day: birthdayData.day, - monthName: getMonthName(birthdayData.month) - }; - } catch (error) { - logger.error('Error in getUserBirthday service', { - error: error.message, - userId, - guildId - }); - throw error; - } -} - -export async function getAllBirthdays(client, guildId) { - try { - const birthdays = await getGuildBirthdays(client, guildId); - - if (!birthdays || Object.keys(birthdays).length === 0) { - return []; - } - - const sortedBirthdays = Object.entries(birthdays) - .map(([userId, data]) => ({ - userId, - month: data.month, - day: data.day, - monthName: getMonthName(data.month) - })) - .sort((a, b) => { - if (a.month !== b.month) return a.month - b.month; - return a.day - b.day; - }); - - return sortedBirthdays; - } catch (error) { - logger.error('Error in getAllBirthdays service', { - error: error.message, - guildId - }); - throw error; - } -} - -export async function deleteBirthday(client, guildId, userId) { - try { - - const birthday = await getUserBirthday(client, guildId, userId); - - if (!birthday) { - return { - status: 'not_found', - }; - } - - const success = await dbDeleteBirthday(client, guildId, userId); - - if (!success) { - throw new TitanBotError( - 'Failed to delete birthday from database', - ErrorTypes.DATABASE, - 'Failed to remove your birthday. Please try again.', - { userId, guildId } - ); - } - - logger.info('Birthday removed successfully', { - userId, - guildId - }); - - return { - status: 'removed', - }; - } catch (error) { - logger.error('Error in deleteBirthday service', { - error: error.message, - userId, - guildId - }); - throw error; - } -} - -export async function getUpcomingBirthdays(client, guildId, limit = 5) { - try { - const birthdays = await getGuildBirthdays(client, guildId); - - if (!birthdays || Object.keys(birthdays).length === 0) { - return []; - } - - const today = new Date(); - const currentYear = today.getFullYear(); - - const upcomingBirthdays = []; - - for (const [userId, userData] of Object.entries(birthdays)) { - let nextBirthday = new Date(currentYear, userData.month - 1, userData.day); - - if (nextBirthday < today) { - nextBirthday = new Date(currentYear + 1, userData.month - 1, userData.day); - } - - const daysUntil = Math.ceil((nextBirthday - today) / (1000 * 60 * 60 * 24)); - - upcomingBirthdays.push({ - userId, - month: userData.month, - day: userData.day, - monthName: getMonthName(userData.month), - date: nextBirthday, - daysUntil - }); - } - - upcomingBirthdays.sort((a, b) => a.daysUntil - b.daysUntil); - - return upcomingBirthdays.slice(0, limit); - } catch (error) { - logger.error('Error in getUpcomingBirthdays service', { - error: error.message, - guildId, - limit - }); - throw error; - } -} - -export async function getTodaysBirthdays(client, guildId) { - try { - const birthdays = await getGuildBirthdays(client, guildId); - const today = new Date(); - const currentMonth = today.getUTCMonth() + 1; - const currentDay = today.getUTCDate(); - - const todaysBirthdays = []; - - for (const [userId, userData] of Object.entries(birthdays)) { - if (userData.month === currentMonth && userData.day === currentDay) { - todaysBirthdays.push({ - userId, - month: userData.month, - day: userData.day, - monthName: getMonthName(userData.month) - }); - } - } - - return todaysBirthdays; - } catch (error) { - logger.error('Error in getTodaysBirthdays service', { - error: error.message, - guildId - }); - throw error; - } -} - -export async function checkBirthdays(client) { - const today = new Date(); - const currentMonth = today.getUTCMonth() + 1; - const currentDay = today.getUTCDate(); - - if (process.env.NODE_ENV !== 'production') { - logger.debug(`🎂 Running daily birthday check for UTC: ${currentMonth}/${currentDay}.`); - } - - for (const [guildId, guild] of client.guilds.cache) { - try { - const config = await getGuildConfig(client, guildId); - const { birthdayChannelId, birthdayRoleId } = config; - - // A channel is required for announcements; the birthday role is optional. - if (!birthdayChannelId) { - if (process.env.NODE_ENV !== 'production') { - logger.debug(`Skipping birthday check for ${guild.name}: Missing channel config.`); - } - continue; - } - - const channel = await guild.channels.fetch(birthdayChannelId).catch(() => null); - if (!channel) continue; - - const trackingKey = getBirthdayTrackingKey(guildId); - const trackingData = (await client.db.get(trackingKey)) || {}; - const updatedTrackingData = { ...trackingData }; - - for (const userId of Object.keys(trackingData)) { - try { - if (birthdayRoleId) { - const member = await guild.members.fetch(userId).catch(() => null); - if (member && member.roles.cache.has(birthdayRoleId)) { - await member.roles.remove(birthdayRoleId, "Birthday role expired"); - } - } - delete updatedTrackingData[userId]; - } catch (error) { - logger.error(`Error removing birthday role from ${userId}:`, error); - } - } - - if (Object.keys(updatedTrackingData).length !== Object.keys(trackingData).length) { - await client.db.set(trackingKey, updatedTrackingData); - } - - // Use the canonical birthday storage (guild::birthdays) that set/remove commands write to. - const birthdays = (await getGuildBirthdays(client, guildId)) || {}; - const birthdayMembers = []; - for (const [userId, userData] of Object.entries(birthdays)) { - if (userData.month === currentMonth && userData.day === currentDay) { - const member = await guild.members.fetch(userId).catch(() => null); - if (member) { - birthdayMembers.push(member); - if (birthdayRoleId) { - try { - await member.roles.add(birthdayRoleId, "Happy Birthday! 🎉"); - updatedTrackingData[userId] = true; - } catch (error) { - logger.error(`Error adding birthday role to ${member.user.tag}:`, error); - } - } - } - } - } - - if (birthdayMembers.length > 0) { - await client.db.set(trackingKey, updatedTrackingData); - const mentionList = birthdayMembers.map(m => m.toString()).join(', '); - - await channel.send({ - embeds: [{ - title: '🎉 Happy Birthday! 🎂', - description: `A very happy birthday to ${mentionList}! Wishing you an amazing day! 🎈`, - color: 0xff69b4, - footer: { text: 'Birthday Bot' }, - timestamp: new Date() - }] - }); - } - } catch (error) { - logger.error(`Error processing birthdays for guild ${guildId}:`, error); - } - } -} \ No newline at end of file diff --git a/src/services/clanService.js b/src/services/clanService.js new file mode 100644 index 0000000000..d9d56d7f51 --- /dev/null +++ b/src/services/clanService.js @@ -0,0 +1,108 @@ +import { logger } from '../utils/logger.js'; + +const CLAN_KEY_PREFIX = 'clans:'; + +function key(guildId) { + return `${CLAN_KEY_PREFIX}${guildId}`; +} + +function normalizeClan(clan) { + return { + id: String(clan.id), + name: String(clan.name), + ownerId: String(clan.ownerId), + roleId: String(clan.roleId), + categoryId: String(clan.categoryId), + textChannelId: String(clan.textChannelId), + voiceChannelId: String(clan.voiceChannelId), + memberIds: Array.from(new Set((clan.memberIds || []).map(String))), + createdAt: clan.createdAt || new Date().toISOString(), + }; +} + +export async function getClans(client, guildId) { + const data = await client.db.get(key(guildId), { clans: [] }); + return Array.isArray(data?.clans) ? data.clans.map(normalizeClan) : []; +} + +async function saveClans(client, guildId, clans) { + await client.db.set(key(guildId), { clans }); + return clans; +} + +export async function getClan(client, guildId, clanId) { + const clans = await getClans(client, guildId); + return clans.find((clan) => clan.id === clanId) || null; +} + +export async function getClanForUser(client, guildId, userId) { + const clans = await getClans(client, guildId); + return clans.find((clan) => clan.ownerId === userId || clan.memberIds.includes(userId)) || null; +} + +export async function createClan(client, guildId, clan) { + const clans = await getClans(client, guildId); + if (clans.some((item) => item.name.toLowerCase() === clan.name.toLowerCase())) { + throw new Error('A clan with this name already exists.'); + } + if (clans.some((item) => item.ownerId === clan.ownerId)) { + throw new Error('This user already owns a clan.'); + } + + const normalized = normalizeClan(clan); + clans.push(normalized); + await saveClans(client, guildId, clans); + return normalized; +} + +export async function updateClan(client, guildId, clanId, updates) { + const clans = await getClans(client, guildId); + const index = clans.findIndex((clan) => clan.id === clanId); + if (index === -1) throw new Error('Clan not found.'); + + const updated = normalizeClan({ ...clans[index], ...updates }); + clans[index] = updated; + await saveClans(client, guildId, clans); + return updated; +} + +export async function deleteClan(client, guildId, clanId) { + const clans = await getClans(client, guildId); + const filtered = clans.filter((clan) => clan.id !== clanId); + if (filtered.length === clans.length) throw new Error('Clan not found.'); + await saveClans(client, guildId, filtered); + return true; +} + +export async function addClanMember(client, guildId, clanId, userId) { + const clan = await getClan(client, guildId, clanId); + if (!clan) throw new Error('Clan not found.'); + if (!clan.memberIds.includes(userId)) clan.memberIds.push(userId); + return updateClan(client, guildId, clanId, { memberIds: clan.memberIds }); +} + +export async function removeClanMember(client, guildId, clanId, userId) { + const clan = await getClan(client, guildId, clanId); + if (!clan) throw new Error('Clan not found.'); + return updateClan(client, guildId, clanId, { + memberIds: clan.memberIds.filter((id) => id !== userId), + }); +} + +export function makeClanId() { + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} + +export function sanitizeClanName(name) { + return String(name || '') + .normalize('NFKC') + .replace(/[\x00-\x1F\x7F]/g, '') + .replace(/[@#:`]/g, '') + .trim() + .replace(/\s+/g, ' ') + .slice(0, 50); +} + +export function clanLoggerError(context, error) { + logger.error(`Clan system error (${context}):`, error); +} diff --git a/src/services/economyService.js b/src/services/economyService.js deleted file mode 100644 index 9203754280..0000000000 --- a/src/services/economyService.js +++ /dev/null @@ -1,463 +0,0 @@ -// economyService.js - -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 { - - await setEconomyData(client, guildId, senderId, senderData); - - try { - - await setEconomyData(client, guildId, receiverId, receiverData); - } catch (receiverError) { - - 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); - - } - - 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 { - 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; \ No newline at end of file diff --git a/src/services/moderation/warningService.js b/src/services/moderation/warningService.js index b0832ecfe3..2c2cd8403f 100644 --- a/src/services/moderation/warningService.js +++ b/src/services/moderation/warningService.js @@ -5,80 +5,37 @@ import { logger } from '../../utils/logger.js'; import { createError, ErrorTypes, wrapServiceClassMethods } from '../../utils/errorHandler.js'; class WarningService { - - static async addWarning({ - guildId, - userId, - moderatorId, - reason, - timestamp = Date.now() - }) { + static async addWarning({ guildId, userId, moderatorId, reason, timestamp = Date.now() }) { const key = getWarningsKey(guildId, userId); const warnings = await getFromDb(key, []); - if (!Array.isArray(warnings)) { logger.warn(`Warnings for ${userId} in ${guildId} corrupted, resetting`); await setInDb(key, []); - throw createError( - 'Corrupted warning data', - ErrorTypes.DATABASE, - 'Warning data was corrupted and has been reset. Please try again.', - { guildId, userId, service: 'warningService', operation: 'addWarning' } - ); + throw createError('Corrupted warning data', ErrorTypes.DATABASE, 'Warning data was corrupted and has been reset. Please try again.', { guildId, userId, service: 'warningService', operation: 'addWarning' }); } - - const warning = { - id: Date.now(), - guildId, - userId, - moderatorId, - reason, - timestamp, - status: 'active' - }; - + const warning = { id: Date.now(), guildId, userId, moderatorId, reason, timestamp, status: 'active' }; warnings.push(warning); await setInDb(key, warnings); - logger.info(`Warning added: ${userId} in ${guildId} by ${moderatorId}`); - - return { - id: warning.id, - totalCount: warnings.length - }; + return { id: warning.id, totalCount: warnings.length }; } static async getWarnings(guildId, userId) { - const key = getWarningsKey(guildId, userId); - const warnings = await getFromDb(key, []); - - return Array.isArray(warnings) - ? warnings.filter(w => w && w.status !== 'deleted') - : []; + const warnings = await getFromDb(getWarningsKey(guildId, userId), []); + return Array.isArray(warnings) ? warnings.filter(w => w && w.status !== 'deleted') : []; } static async getWarningCount(guildId, userId) { - const warnings = await this.getWarnings(guildId, userId); - return warnings.length; + return (await this.getWarnings(guildId, userId)).length; } static async removeWarning(guildId, userId, warningId) { const key = getWarningsKey(guildId, userId); const warnings = await getFromDb(key, []); - const index = warnings.findIndex(w => w.id === warningId); - if (index === -1) { - throw createError( - 'Warning not found', - ErrorTypes.USER_INPUT, - 'That warning could not be found. It may have already been removed.', - { guildId, userId, warningId, service: 'warningService', operation: 'removeWarning' } - ); - } - + if (index === -1) throw createError('Warning not found', ErrorTypes.USER_INPUT, 'That warning could not be found. It may have already been removed.', { guildId, userId, warningId, service: 'warningService', operation: 'removeWarning' }); warnings[index].status = 'deleted'; await setInDb(key, warnings); - logger.info(`Warning removed: ${warningId} for ${userId} in ${guildId}`); return { removed: true }; } @@ -86,25 +43,28 @@ class WarningService { static async clearWarnings(guildId, userId) { const key = getWarningsKey(guildId, userId); const warnings = await getFromDb(key, []); - const count = warnings.length; - + const count = Array.isArray(warnings) ? warnings.length : 0; await setInDb(key, []); - logger.info(`Warnings cleared for ${userId} in ${guildId} (${count} removed)`); return { count }; } - static async getGuildWarnings(guildId, filters = {}) { - const { moderatorId, limit = 100 } = filters; - const prefix = getWarningsPrefix(guildId); + static async getGuildWarnings(guildId, filters = {}, legacyFilters = undefined) { + // Supports both getGuildWarnings(guildId, filters) and the accidental + // getGuildWarnings(client, guildId, filters) call used by older integrations. + if (typeof guildId === 'object' && typeof filters === 'string') { + guildId = filters; + filters = legacyFilters || {}; + } + const { moderatorId, limit = 100 } = filters || {}; + const prefix = getWarningsPrefix(guildId); const keys = await db.list(prefix); const allWarnings = []; for (const key of Array.isArray(keys) ? keys : []) { const warnings = await getFromDb(key, []); if (!Array.isArray(warnings)) continue; - for (const warning of warnings) { if (!warning || warning.status === 'deleted') continue; if (moderatorId && warning.moderatorId !== moderatorId) continue; @@ -113,12 +73,10 @@ class WarningService { } allWarnings.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0)); - logger.debug(`Fetched guild warnings for ${guildId} with ${allWarnings.length} total`); return allWarnings.slice(0, limit); } } wrapServiceClassMethods(WarningService); - export { WarningService }; diff --git a/src/services/security/antiNuke.js b/src/services/security/antiNuke.js new file mode 100644 index 0000000000..8fce835c99 --- /dev/null +++ b/src/services/security/antiNuke.js @@ -0,0 +1,126 @@ +import { AuditLogEvent, PermissionFlagsBits } from 'discord.js'; +import { getSecurityConfig, isWhitelisted, sendSecurityLog } from './securityService.js'; +import { logger } from '../../utils/logger.js'; + +const counters = new Map(); +const EVENT_MAP = { + channelDelete: AuditLogEvent.ChannelDelete, + channelCreate: AuditLogEvent.ChannelCreate, + roleDelete: AuditLogEvent.RoleDelete, + roleCreate: AuditLogEvent.RoleCreate, + roleUpdate: AuditLogEvent.RoleUpdate, + webhookUpdate: [AuditLogEvent.WebhookCreate, AuditLogEvent.WebhookUpdate, AuditLogEvent.WebhookDelete], + webhookDelete: AuditLogEvent.WebhookDelete, + ban: AuditLogEvent.MemberBanAdd, + kick: AuditLogEvent.MemberKick, + botAdd: AuditLogEvent.BotAdd, +}; +function key(guildId, executorId, type) { return `${guildId}:${executorId}:${type}`; } +function getRecentCount(store, mapKey, now, windowMs) { + const existing = store.get(mapKey) || []; + return existing.filter(timestamp => now - timestamp <= windowMs); +} + +async function findExecutor(guild, auditType, targetId = null) { + const types = Array.isArray(auditType) ? auditType : [auditType]; + const entries = []; + for (const type of types) { + const logs = await guild.fetchAuditLogs({ type, limit: 10 }).catch(() => null); + if (!logs?.entries) continue; + for (const entry of logs.entries.values()) { + if (Date.now() - entry.createdTimestamp > 15000) continue; + if (targetId && entry.target?.id !== targetId && entry.targetId !== targetId) continue; + entries.push(entry); + } + } + entries.sort((a, b) => b.createdTimestamp - a.createdTimestamp); + return entries[0]?.executor || null; +} + +async function stripDangerousRoles(member, reason) { + if (!member.manageable) return false; + const removable = member.roles.cache.filter(role => { + if (role.id === member.guild.id || !role.editable) return false; + return role.permissions.any([ + PermissionFlagsBits.Administrator, + PermissionFlagsBits.ManageGuild, + PermissionFlagsBits.ManageChannels, + PermissionFlagsBits.ManageRoles, + PermissionFlagsBits.BanMembers, + PermissionFlagsBits.KickMembers, + PermissionFlagsBits.ManageWebhooks, + ]); + }); + if (!removable.size) return false; + await member.roles.remove(removable, `Anti-Nuke: ${reason}`).catch(() => {}); + return true; +} + +async function punishExecutor(guild, executor, config, type, reason, targetId) { + const member = await guild.members.fetch(executor.id).catch(() => null); + if (!member || member.id === guild.ownerId || isWhitelisted(member, config)) return false; + + const action = config.antiNuke.punishments?.[type] || config.antiNuke.action || 'strip'; + let actionTaken = action; + if (action === 'ban' && member.bannable) await member.ban({ reason: `Anti-Nuke: ${reason}` }).catch(() => {}); + else if (action === 'kick' && member.kickable) await member.kick(`Anti-Nuke: ${reason}`).catch(() => {}); + else if (action === 'timeout' && member.moderatable) await member.timeout(10 * 60 * 1000, `Anti-Nuke: ${reason}`).catch(() => {}); + else if (action === 'strip') { + const stripped = await stripDangerousRoles(member, reason); + actionTaken = stripped ? 'strip' : 'none'; + } + + if (type === 'botAdd' && targetId) { + const bot = await guild.members.fetch(targetId).catch(() => null); + if (bot?.user?.bot && bot.kickable && bot.id !== guild.client.user?.id) await bot.kick('Anti-Nuke: unauthorized bot addition').catch(() => {}); + } + + await sendSecurityLog(guild.client, guild, { + title: 'Anti-Nuke Triggered', + description: `**${executor.tag || executor.username}** triggered Anti-Nuke protection.`, + fields: [ + { name: 'Operation', value: type, inline: true }, + { name: 'Reason', value: reason.slice(0, 1024), inline: true }, + { name: 'Action', value: actionTaken, inline: true }, + { name: 'Executor', value: executor.id, inline: true }, + ], + }); + return true; +} + +export async function handleAntiNuke(guild, type, targetId = null) { + const config = await getSecurityConfig(guild.client, guild.id); + if (!config.enabled || !config.antiNuke.enabled) return; + const auditType = EVENT_MAP[type]; + const threshold = Number(config.antiNuke.thresholds?.[type] || 0); + if (!auditType || threshold <= 0) return; + + const auditTargetId = type.startsWith('webhook') ? null : targetId; + const executor = await findExecutor(guild, auditType, auditTargetId); + if (!executor || executor.id === guild.client.user?.id) return; + const member = await guild.members.fetch(executor.id).catch(() => null); + if (!member || member.id === guild.ownerId || isWhitelisted(member, config)) return; + + const now = Date.now(); + const counterKey = key(guild.id, executor.id, type); + const recent = getRecentCount(counters, counterKey, now, config.antiNuke.windowMs); + recent.push(now); + counters.set(counterKey, recent); + if (recent.length >= threshold) { + await punishExecutor(guild, executor, config, type, `${type} threshold exceeded (${recent.length}/${threshold})`, targetId); + counters.delete(counterKey); + } +} + +export function registerAntiNukeEvent(eventName, type) { + return { + name: eventName, + async execute(eventTarget) { + const guild = eventTarget?.guild || eventTarget; + const targetId = eventTarget?.id || null; + if (!guild?.id) return; + try { await handleAntiNuke(guild, type, targetId); } + catch (error) { logger.error(`Anti-Nuke ${type} failed`, { error: error.message, guildId: guild.id }); } + }, + }; +} diff --git a/src/services/security/antiRaid.js b/src/services/security/antiRaid.js new file mode 100644 index 0000000000..b3bd91cbe4 --- /dev/null +++ b/src/services/security/antiRaid.js @@ -0,0 +1,88 @@ +import { PermissionFlagsBits } from 'discord.js'; +import { getSecurityConfig, isWhitelisted, sendSecurityLog } from './securityService.js'; + +const joins = new Map(); +const lockdowns = new Map(); + +async function restoreLockdown(guild, state) { + if (!state) return; + for (const [channelId, previous] of state.channels) { + const channel = guild.channels.cache.get(channelId) || await guild.channels.fetch(channelId).catch(() => null); + if (!channel?.permissionOverwrites?.edit) continue; + const everyoneId = guild.roles.everyone.id; + const current = channel.permissionOverwrites.cache.get(everyoneId); + if (!previous) { if (current) await current.delete('Anti-Raid lockdown ended').catch(() => {}); continue; } + await channel.permissionOverwrites.edit(guild.roles.everyone, { SendMessages: previous.sendMessages }, { reason: 'Anti-Raid lockdown ended' }).catch(() => {}); + } +} + +async function startLockdown(guild, config) { + if (lockdowns.has(guild.id)) return; + const me = guild.members.me; + if (!me?.permissions.has(PermissionFlagsBits.ManageChannels)) return; + const state = { expiresAt: Date.now() + config.antiRaid.lockdownMs, channels: new Map() }; + for (const channel of guild.channels.cache.values()) { + if (!channel.permissionOverwrites?.edit || channel.isThread?.()) continue; + const overwrite = channel.permissionOverwrites.cache.get(guild.roles.everyone.id); + state.channels.set(channel.id, { sendMessages: overwrite?.deny?.has(PermissionFlagsBits.SendMessages) ? false : overwrite?.allow?.has(PermissionFlagsBits.SendMessages) ? true : null }); + await channel.permissionOverwrites.edit(guild.roles.everyone, { SendMessages: false }, { reason: 'Anti-Raid lockdown' }).catch(() => {}); + } + lockdowns.set(guild.id, state); + const timer = setTimeout(async () => { + const current = lockdowns.get(guild.id); + if (current !== state) return; + lockdowns.delete(guild.id); + await restoreLockdown(guild, state); + await sendSecurityLog(guild.client, guild, { title: 'Anti-Raid Lockdown Ended', description: 'The temporary raid lockdown has ended and previous channel permissions were restored.', color: 0x57F287 }); + }, Math.max(1000, config.antiRaid.lockdownMs)); + timer.unref?.(); +} + +async function punishMember(member, action, reason, timeoutMs) { + if (action === 'ban' && member.bannable) await member.ban({ reason: `Anti-Raid: ${reason}` }).catch(() => {}); + else if (action === 'kick' && member.kickable) await member.kick(`Anti-Raid: ${reason}`).catch(() => {}); + else if (action === 'timeout' && member.moderatable) await member.timeout(Math.min(Math.max(timeoutMs || 600000, 1000), 2419200000), `Anti-Raid: ${reason}`).catch(() => {}); +} + +export async function handleMemberJoin(member) { + const guild = member.guild; + const client = guild.client; + const config = await getSecurityConfig(client, guild.id); + if (!config.enabled || !config.antiRaid.enabled || isWhitelisted(member, config)) return; + + const now = Date.now(); + const list = (joins.get(guild.id) || []).filter(t => now - t <= config.antiRaid.windowMs); + list.push(now); + joins.set(guild.id, list); + + const accountTooNew = now - member.user.createdTimestamp < config.antiRaid.minAccountAgeMs; + const raidDetected = list.length >= config.antiRaid.joins; + if (raidDetected || accountTooNew) { + const reason = raidDetected ? 'raid detected' : 'new account'; + const action = config.antiRaid.punishment || config.antiRaid.action || 'timeout'; + await punishMember(member, action, reason, config.antiRaid.timeoutMs); + await sendSecurityLog(client, guild, { + title: raidDetected ? 'Raid Detected' : 'New Account Protection', + description: `${member} was flagged by Anti-Raid.`, + fields: [ + { name: 'Account Age', value: ``, inline: true }, + { name: 'Joins in Window', value: String(list.length), inline: true }, + { name: 'Punishment', value: action, inline: true }, + ], + }); + } + + if (raidDetected && config.antiRaid.lockdown) await startLockdown(guild, config); +} + +export async function clearRaidLockdown(guild) { + const state = lockdowns.get(guild.id); + if (!state) return false; + lockdowns.delete(guild.id); + await restoreLockdown(guild, state); + return true; +} +export function isRaidLockdownActive(guildId) { + const state = lockdowns.get(guildId); + return Boolean(state && state.expiresAt > Date.now()); +} diff --git a/src/services/security/autoMod.js b/src/services/security/autoMod.js new file mode 100644 index 0000000000..ab600e0f4a --- /dev/null +++ b/src/services/security/autoMod.js @@ -0,0 +1,110 @@ +import { getSecurityConfig, isWhitelisted, addStrike, sendSecurityLog } from './securityService.js'; + +const userMessages = new Map(); +const duplicates = new Map(); +const inviteRegex = /(discord\.gg|discord(?:app)?\.com\/invite)\/[^\s]+/i; +const urlRegex = /https?:\/\/[^\s]+/i; +const repeatedCharRegex = /(.)\1{8,}/u; + +function getState(map, key) { + if (!map.has(key)) map.set(key, []); + return map.get(key); +} + +function detect(message, config) { + const text = message.content || ''; + const reasons = []; + const now = Date.now(); + const key = `${message.guild.id}:${message.author.id}`; + + if (config.autoMod.spam.enabled) { + const list = getState(userMessages, key).filter(t => now - t <= config.autoMod.spam.windowMs); + list.push(now); + userMessages.set(key, list); + if (list.length >= config.autoMod.spam.maxMessages) reasons.push({ type: 'spam', reason: `message spam (${config.autoMod.spam.maxMessages}/${Math.round(config.autoMod.spam.windowMs / 1000)}s)` }); + } + + const normalized = text.trim().slice(0, 500).toLowerCase(); + if (config.autoMod.duplicate.enabled && normalized) { + const list = getState(duplicates, key).filter(x => now - x.time <= config.autoMod.duplicate.windowMs); + list.push({ text: normalized, time: now }); + duplicates.set(key, list); + if (list.filter(x => x.text === normalized).length >= config.autoMod.duplicate.maxRepeats) reasons.push({ type: 'duplicate', reason: `duplicate spam (${config.autoMod.duplicate.maxRepeats})` }); + } + + const mentionCount = message.mentions.users.size + message.mentions.roles.size; + if (config.autoMod.mentions.enabled && mentionCount >= config.autoMod.mentions.max) reasons.push({ type: 'mentions', reason: `mention spam (${mentionCount})` }); + if (config.autoMod.mentions.enabled && (message.mentions.everyone || /@(everyone|here)/i.test(text))) reasons.push({ type: 'mentions', reason: 'everyone/here mention' }); + if (config.autoMod.invites.enabled && inviteRegex.test(text)) reasons.push({ type: 'invites', reason: 'Discord invite link' }); + if (config.autoMod.links.enabled && urlRegex.test(text)) reasons.push({ type: 'links', reason: 'external link' }); + if (config.autoMod.badWords.enabled && config.autoMod.badWords.words.some(word => { + const escaped = String(word).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`(^|\\s)${escaped}(?=$|\\s|[.!?,])`, 'i').test(text); + })) reasons.push({ type: 'badWords', reason: 'blocked word' }); + if (repeatedCharRegex.test(text)) reasons.push({ type: 'spam', reason: 'character spam' }); + + if (config.autoMod.caps.enabled) { + const letters = text.match(/[A-Za-z]/g) || []; + const upper = text.match(/[A-Z]/g) || []; + if (letters.length >= config.autoMod.caps.minLength && upper.length / letters.length >= config.autoMod.caps.ratio) reasons.push({ type: 'caps', reason: 'excessive caps' }); + } + + const seen = new Set(); + return reasons.filter(item => !seen.has(item.type) && seen.add(item.type)); +} + +async function executeAction(message, action, duration, reason, strike) { + const member = await message.guild.members.fetch(message.author.id).catch(() => null); + const config = await getSecurityConfig(message.client, message.guild.id); + + // Whitelist is an absolute bypass: no deletion, timeout, kick, ban, or warning. + if (!member || isWhitelisted(member, config)) return false; + + const deleted = await message.delete().then(() => true).catch(() => false); + + if (action === 'timeout' && member.moderatable) await member.timeout(Math.min(Math.max(duration || 60000, 1000), 2419200000), `AutoMod: ${reason}`).catch(() => {}); + else if (action === 'kick' && member.kickable) await member.kick(`AutoMod: ${reason}`).catch(() => {}); + else if (action === 'ban' && member.bannable) await member.ban({ reason: `AutoMod: ${reason}` }).catch(() => {}); + + await sendSecurityLog(message.client, message.guild, { + title: 'AutoMod Action', + description: `${message.author} triggered AutoMod.`, + fields: [ + { name: 'Rule', value: reason.split(':')[0].slice(0, 100), inline: true }, + { name: 'Reason', value: reason.slice(0, 900), inline: true }, + { name: 'Strike', value: String(strike.count), inline: true }, + { name: 'Action', value: action, inline: true }, + { name: 'Message Deleted', value: deleted ? 'Yes' : 'No', inline: true }, + ], + }); + return true; +} + +export async function handleAutoMod(message) { + if (!message.guild || message.author.bot) return false; + const config = await getSecurityConfig(message.client, message.guild.id); + if (!config.enabled || !config.autoMod.enabled || config.ignoredChannels?.includes(message.channel.id)) return false; + + const member = message.member || await message.guild.members.fetch(message.author.id).catch(() => null); + if (!member || member.id === message.guild.ownerId || isWhitelisted(member, config)) return false; + + const violations = detect(message, config); + if (!violations.length) return false; + + // Re-check immediately before changing strike state in case the whitelist was changed during detection. + const latestConfig = await getSecurityConfig(message.client, message.guild.id); + const latestMember = await message.guild.members.fetch(message.author.id).catch(() => member); + if (!latestMember || latestMember.id === message.guild.ownerId || isWhitelisted(latestMember, latestConfig)) return false; + + const primary = violations[0]; + const reason = violations.map(v => `${v.type}: ${v.reason}`).join(', '); + const strike = await addStrike(message.client, message.guild.id, message.author.id, reason); + const rule = latestConfig.autoMod[primary.type] || {}; + const baseAction = rule.punishment || latestConfig.autoMod.action || 'delete'; + const escalation = strike.count > 1 ? latestConfig.escalation?.find(item => item.strike === strike.count) : null; + const action = escalation?.action || baseAction; + const duration = escalation?.durationMs || rule.timeoutMs || 60000; + + await executeAction(message, action, duration, reason, strike); + return true; +} diff --git a/src/services/security/securityDashboard.js b/src/services/security/securityDashboard.js new file mode 100644 index 0000000000..429524d928 --- /dev/null +++ b/src/services/security/securityDashboard.js @@ -0,0 +1,129 @@ +import { getSecurityConfig, updateSecurityConfig } from './securityService.js'; + +const HTML = ` + + + + +Infinity Security Dashboard + + +
+

Infinity Security

Anti-Nuke • Anti-Raid • Advanced AutoMod • Escalation • Whitelist
+

Dashboard Access

Enter the SECURITY_DASHBOARD_TOKEN configured on the bot host.

+
+`; + +function getAllowedGuild(client, guildId) { + const configured = process.env.SECURITY_DASHBOARD_GUILD_ID || process.env.GUILD_ID || null; + const selectedId = guildId || configured; + if (selectedId) return client.guilds.cache.get(selectedId) || null; + return client.guilds.cache.first() || null; +} + +export function registerSecurityDashboard(app, client) { + const token = process.env.SECURITY_DASHBOARD_TOKEN; + const authorized = req => Boolean(token && req.headers['x-security-token'] && req.headers['x-security-token'] === token); + + app.get('/security', (req, res) => res.type('html').send(HTML)); + + app.get('/api/security/config', async (req, res) => { + if (!authorized(req)) return res.status(401).json({ error: 'Unauthorized' }); + const guild = getAllowedGuild(client, req.query.guildId); + if (!guild) return res.status(503).json({ error: 'Configured guild is not available' }); + try { + const config = await getSecurityConfig(client, guild.id); + res.json({ ...config, guildName: guild.name, guildId: guild.id }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.patch('/api/security/config', async (req, res) => { + if (!authorized(req)) return res.status(401).json({ error: 'Unauthorized' }); + const guild = getAllowedGuild(client, req.query.guildId || req.body?.guildId); + if (!guild) return res.status(503).json({ error: 'Configured guild is not available' }); + try { + const updated = await updateSecurityConfig(client, guild.id, req.body || {}); + res.json({ ...updated, guildName: guild.name, guildId: guild.id }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); +} diff --git a/src/services/security/securityService.js b/src/services/security/securityService.js new file mode 100644 index 0000000000..3b29856ea1 --- /dev/null +++ b/src/services/security/securityService.js @@ -0,0 +1,169 @@ +import { getFromDb, setInDb } from '../../utils/database.js'; +import { logger } from '../../utils/logger.js'; + +const NukeTypes = ['channelDelete','channelCreate','roleDelete','roleCreate','roleUpdate','webhookUpdate','webhookDelete','ban','kick','botAdd']; +const AutoModTypes = ['spam','duplicate','mentions','invites','links','caps','badWords']; +const AUTO_ACTIONS = new Set(['delete', 'timeout', 'kick', 'ban']); + +export const SECURITY_DEFAULTS = { + enabled: true, + antiNuke: { enabled: true, windowMs: 10000, thresholds: { channelDelete: 3, channelCreate: 5, roleDelete: 3, roleCreate: 5, roleUpdate: 1, webhookUpdate: 3, webhookDelete: 2, ban: 5, kick: 5, botAdd: 1 }, action: 'strip', punishments: Object.fromEntries(NukeTypes.map(type => [type, ['ban','kick','botAdd'].includes(type) ? 'ban' : 'strip'])), lockdown: true }, + antiRaid: { enabled: true, joins: 8, windowMs: 10000, minAccountAgeMs: 24 * 60 * 60 * 1000, action: 'timeout', punishment: 'timeout', timeoutMs: 10 * 60 * 1000, lockdown: true, lockdownMs: 10 * 60 * 1000 }, + autoMod: { enabled: true, spam: { enabled: true, maxMessages: 6, windowMs: 5000, punishment: 'delete' }, duplicate: { enabled: true, maxRepeats: 3, windowMs: 10000, punishment: 'delete' }, mentions: { enabled: true, max: 6, punishment: 'delete' }, caps: { enabled: false, ratio: 0.8, minLength: 12, punishment: 'delete' }, invites: { enabled: true, punishment: 'delete' }, links: { enabled: false, punishment: 'delete' }, badWords: { enabled: false, words: [], punishment: 'delete' }, action: 'delete' }, + escalation: [ + { strike: 1, action: 'delete', durationMs: 0 }, { strike: 2, action: 'timeout', durationMs: 60 * 1000 }, { strike: 3, action: 'timeout', durationMs: 10 * 60 * 1000 }, + { strike: 4, action: 'timeout', durationMs: 60 * 60 * 1000 }, { strike: 5, action: 'kick', durationMs: 0 }, { strike: 6, action: 'ban', durationMs: 0 }, + ], + strikeDecayMs: 24 * 60 * 60 * 1000, + whitelist: { users: [], roles: [], bots: [] }, + ignoredChannels: [], logChannelId: null, +}; + +function clone(value) { return JSON.parse(JSON.stringify(value)); } +function deepMerge(base, patch) { + const result = { ...base }; + for (const [key, value] of Object.entries(patch || {})) { + if (value && typeof value === 'object' && !Array.isArray(value) && base[key] && typeof base[key] === 'object' && !Array.isArray(base[key])) result[key] = deepMerge(base[key], value); + else result[key] = value; + } + return result; +} +function configKey(guildId) { return `security:config:${guildId}`; } +function strikeKey(guildId, userId) { return `security:strikes:${guildId}:${userId}`; } +function normalizeIdList(value) { + if (Array.isArray(value)) return value.map(String).filter(Boolean); + if (value instanceof Set) return [...value].map(String).filter(Boolean); + if (value && typeof value === 'object') return Object.keys(value).map(String).filter(Boolean); + return []; +} +function sanitizeConfig(config) { + config.whitelist = config.whitelist || {}; + config.whitelist.users = normalizeIdList(config.whitelist.users); + config.whitelist.roles = normalizeIdList(config.whitelist.roles); + config.whitelist.bots = normalizeIdList(config.whitelist.bots); + config.antiNuke.punishments = { ...SECURITY_DEFAULTS.antiNuke.punishments, ...(config.antiNuke.punishments || {}) }; + for (const type of AutoModTypes) { + config.autoMod[type] ||= clone(SECURITY_DEFAULTS.autoMod[type]); + if (!AUTO_ACTIONS.has(config.autoMod[type].punishment)) config.autoMod[type].punishment = 'delete'; + } + config.escalation = (config.escalation || []).map(level => ({ ...level, action: AUTO_ACTIONS.has(level.action) ? level.action : 'delete' })); + return config; +} + +export async function getSecurityConfig(client, guildId) { + try { return sanitizeConfig(deepMerge(clone(SECURITY_DEFAULTS), await getFromDb(configKey(guildId), null) || {})); } + catch (error) { logger.error('Failed to load security config', { guildId, error: error.message }); return clone(SECURITY_DEFAULTS); } +} +export async function updateSecurityConfig(client, guildId, patch) { + const updated = sanitizeConfig(deepMerge(await getSecurityConfig(client, guildId), patch)); + await setInDb(configKey(guildId), updated); + return updated; +} +export async function getStrikes(client, guildId, userId) { + const value = await getFromDb(strikeKey(guildId, userId), null); + if (!value || typeof value !== 'object') return { count: 0, updatedAt: 0 }; + return value; +} +export async function addStrike(client, guildId, userId, reason = 'AutoMod violation') { + const config = await getSecurityConfig(client, guildId); + const current = await getStrikes(client, guildId, userId); + const now = Date.now(); + const expired = current.updatedAt && now - current.updatedAt > Number(config.strikeDecayMs || 0); + const next = { count: (expired ? 0 : Number(current.count || 0)) + 1, updatedAt: now, lastReason: reason }; + await setInDb(strikeKey(guildId, userId), next); + return next; +} +export async function clearStrikes(client, guildId, userId) { await setInDb(strikeKey(guildId, userId), { count: 0, updatedAt: Date.now(), lastReason: '' }); } + +export function isWhitelisted(member, config) { + if (!member) return false; + const whitelist = config?.whitelist || {}; + const users = normalizeIdList(whitelist.users); + const roles = normalizeIdList(whitelist.roles); + const bots = normalizeIdList(whitelist.bots); + const memberId = String(member.id); + if (users.includes(memberId)) return true; + const roleIds = new Set(); + if (member.roles?.cache) for (const role of member.roles.cache.values()) roleIds.add(String(role.id)); + if (Array.isArray(member._roles)) for (const roleId of member._roles) roleIds.add(String(roleId)); + if ([...roleIds].some(roleId => roles.includes(roleId))) return true; + if (member.user?.bot && bots.includes(memberId)) return true; + return false; +} + +export async function sendSecurityLog(client, guild, payload) { + try { + const config = await getSecurityConfig(client, guild.id); + if (!config.logChannelId) return; + const channel = guild.channels.cache.get(config.logChannelId) || await guild.channels.fetch(config.logChannelId).catch(() => null); + if (!channel?.isTextBased()) return; + await channel.send({ embeds: [{ title: payload.title || 'Security Event', description: payload.description || 'Security event detected.', color: payload.color || 0xED4245, fields: payload.fields || [], timestamp: new Date().toISOString() }] }); + } catch (error) { logger.warn('Failed to send security log', { guildId: guild.id, error: error.message }); } +} + +const autoModState = new Map(); +function autoModKey(guildId, userId) { return `${guildId}:${userId}`; } +function autoModData(guildId, userId) { const key = autoModKey(guildId, userId); let data = autoModState.get(key); if (!data) { data = { messages: [], repeats: [] }; autoModState.set(key, data); } return data; } +function normalizeMessage(content) { return String(content || '').trim().toLowerCase().replace(/\s+/g, ' '); } +function capRatio(content) { const letters = String(content || '').match(/[A-Za-z]/g) || []; const upper = String(content || '').match(/[A-Z]/g) || []; return letters.length ? upper.length / letters.length : 0; } + +async function executeAutoModAction(message, action, duration, reason, config) { + const member = message.member || await message.guild.members.fetch(message.author.id).catch(() => null); + if (!member || isWhitelisted(member, config)) return false; + if (action === 'delete') return true; + if (action === 'timeout' && member.moderatable) await member.timeout(Math.min(Math.max(duration || 60000, 1000), 2419200000), `AutoMod: ${reason}`).catch(() => {}); + else if (action === 'kick' && member.kickable) await member.kick(`AutoMod: ${reason}`).catch(() => {}); + else if (action === 'ban' && member.bannable) await member.ban({ reason: `AutoMod: ${reason}` }).catch(() => {}); + return true; +} + +export async function processAutoMod(message, client) { + if (!message?.guild || message.author?.bot) return false; + const config = await getSecurityConfig(client, message.guild.id); + const a = config.autoMod; + if (!config.enabled || !a?.enabled || config.ignoredChannels?.includes(message.channel.id)) return false; + const member = message.member || await message.guild.members.fetch(message.author.id).catch(() => null); + if (!member || isWhitelisted(member, config)) return false; + + const now = Date.now(); + const data = autoModData(message.guild.id, message.author.id); + data.messages = data.messages.filter(t => now - t <= 60000); + data.repeats = data.repeats.filter(x => now - x.time <= 60000); + data.messages.push(now); + data.repeats.push({ time: now, content: normalizeMessage(message.content) }); + + const content = message.content || ''; + const violations = []; + if (a.spam.enabled && data.messages.filter(t => now - t <= a.spam.windowMs).length >= a.spam.maxMessages) violations.push({ type: 'spam', reason: `message spam (${a.spam.maxMessages}/${Math.round(a.spam.windowMs / 1000)}s)` }); + if (a.duplicate.enabled && data.repeats.filter(x => now - x.time <= a.duplicate.windowMs && x.content === normalizeMessage(content)).length >= a.duplicate.maxRepeats) violations.push({ type: 'duplicate', reason: `duplicate spam (${a.duplicate.maxRepeats})` }); + const mentions = message.mentions.users.size + message.mentions.roles.size; + if (a.mentions.enabled && mentions >= a.mentions.max) violations.push({ type: 'mentions', reason: `mention spam (${mentions})` }); + if (a.invites.enabled && /(?:discord\.gg|discord(?:app)?\.com\/invite)\/\S+/i.test(content)) violations.push({ type: 'invites', reason: 'Discord invite' }); + if (a.links.enabled && /https?:\/\/\S+/i.test(content)) violations.push({ type: 'links', reason: 'link spam' }); + if (a.caps.enabled && content.length >= a.caps.minLength && capRatio(content) >= a.caps.ratio) violations.push({ type: 'caps', reason: 'excessive caps' }); + if (a.badWords.enabled && a.badWords.words?.some(word => word && content.toLowerCase().includes(String(word).toLowerCase()))) violations.push({ type: 'badWords', reason: 'blocked word' }); + if (!violations.length) return false; + + const reason = violations.map(v => v.reason).join(', '); + const strike = await addStrike(client, message.guild.id, message.author.id, reason); + const escalation = config.escalation?.find(item => item.strike === strike.count); + const primary = violations[0]; + const action = escalation?.action || a[primary.type]?.punishment || 'delete'; + const duration = escalation?.durationMs || 60000; + + const latestConfig = await getSecurityConfig(client, message.guild.id); + const latestMember = message.member || await message.guild.members.fetch(message.author.id).catch(() => null); + if (!latestMember || isWhitelisted(latestMember, latestConfig)) return false; + + await message.delete().catch(() => {}); + await executeAutoModAction(message, action, duration, reason, latestConfig); + await sendSecurityLog(client, message.guild, { title: 'AutoMod Triggered', description: `AutoMod acted on **${message.author.tag}**.`, fields: [ + { name: 'Rule', value: primary.type, inline: true }, { name: 'Reason', value: reason.slice(0, 1024), inline: true }, { name: 'Strike', value: String(strike.count), inline: true }, { name: 'Action', value: action, inline: true }, + ] }); + return true; +} + +export function getRecentCount(store, mapKey, now, windowMs) { + const existing = store.get(mapKey) || []; + return existing.filter(timestamp => now - timestamp <= windowMs); +} diff --git a/src/services/staffService.js b/src/services/staffService.js new file mode 100644 index 0000000000..0adbb8fdc3 --- /dev/null +++ b/src/services/staffService.js @@ -0,0 +1,97 @@ +import { getFromDb, setInDb } from '../utils/database.js'; + +const key = (guildId) => `guild:${guildId}:staff`; + +const DEFAULTS = { + config: { promotionChannelId: null, demotionChannelId: null, warningChannelId: null, notesChannelId: null, managerRoleId: null, warningsBeforeReview: 3 }, + members: {}, + ticketLogMessages: {}, +}; + +function normalize(data) { + const value = data && typeof data === 'object' ? data : {}; + return { + config: { ...DEFAULTS.config, ...(value.config || {}) }, + members: value.members && typeof value.members === 'object' ? value.members : {}, + ticketLogMessages: value.ticketLogMessages && typeof value.ticketLogMessages === 'object' ? value.ticketLogMessages : {}, + }; +} + +export async function getStaffData(guildId) { return normalize(await getFromDb(key(guildId), DEFAULTS)); } +export async function saveStaffData(guildId, data) { const normalized = normalize(data); await setInDb(key(guildId), normalized); return normalized; } + +export async function getStaffProfile(guildId, userId, defaults = {}) { + const data = await getStaffData(guildId); + const existing = data.members[userId] || {}; + return { userId, joinedAt: existing.joinedAt || defaults.joinedAt || new Date().toISOString(), warnings: Array.isArray(existing.warnings) ? existing.warnings : [], promotions: Array.isArray(existing.promotions) ? existing.promotions : [], demotions: Array.isArray(existing.demotions) ? existing.demotions : [], notes: Array.isArray(existing.notes) ? existing.notes : [], activity: existing.activity && typeof existing.activity === 'object' ? existing.activity : {}, ...existing }; +} + +export async function ensureStaffMember(guildId, userId, defaults = {}) { const data = await getStaffData(guildId); const profile = await getStaffProfile(guildId, userId, defaults); data.members[userId] = profile; await saveStaffData(guildId, data); return profile; } +export async function updateStaffConfig(guildId, patch) { const data = await getStaffData(guildId); data.config = { ...data.config, ...patch }; return saveStaffData(guildId, data); } + +export async function addStaffWarning(guildId, userId, issuerId, reason) { + const data = await getStaffData(guildId); const profile = await getStaffProfile(guildId, userId); + const warning = { id: `sw_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, reason: String(reason).trim(), issuerId, createdAt: new Date().toISOString() }; + profile.warnings.push(warning); data.members[userId] = profile; await saveStaffData(guildId, data); return warning; +} +export async function removeStaffWarning(guildId, userId, warningId) { const data = await getStaffData(guildId); const profile = await getStaffProfile(guildId, userId); const before = profile.warnings.length; profile.warnings = profile.warnings.filter((warning) => warning.id !== warningId); data.members[userId] = profile; await saveStaffData(guildId, data); return before !== profile.warnings.length; } + +export async function addPromotion(guildId, userId, record) { const data = await getStaffData(guildId); const profile = await getStaffProfile(guildId, userId); profile.promotions.push({ id: `sp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, ...record, createdAt: new Date().toISOString() }); data.members[userId] = profile; await saveStaffData(guildId, data); return profile.promotions.at(-1); } +export async function addDemotion(guildId, userId, record) { const data = await getStaffData(guildId); const profile = await getStaffProfile(guildId, userId); profile.demotions.push({ id: `sd_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, ...record, createdAt: new Date().toISOString() }); data.members[userId] = profile; await saveStaffData(guildId, data); return profile.demotions.at(-1); } +export async function addStaffNote(guildId, userId, authorId, note) { const data = await getStaffData(guildId); const profile = await getStaffProfile(guildId, userId); profile.notes.push({ id: `sn_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, note: String(note).trim(), authorId, createdAt: new Date().toISOString() }); data.members[userId] = profile; await saveStaffData(guildId, data); return profile.notes.at(-1); } + +export async function resetStaffProfile(guildId, userId) { + const data = await getStaffData(guildId); + const profile = await getStaffProfile(guildId, userId); + profile.warnings = []; + profile.notes = []; + profile.activity = {}; + data.members[userId] = profile; + await saveStaffData(guildId, data); + return profile; +} + +export async function incrementStaffActivity(guildId, userId, type, amount = 1) { const data = await getStaffData(guildId); const profile = await getStaffProfile(guildId, userId); profile.activity = profile.activity || {}; profile.activity[type] = Math.max(0, Number(profile.activity[type] || 0) + Number(amount || 0)); profile.activity.lastActiveAt = new Date().toISOString(); data.members[userId] = profile; await saveStaffData(guildId, data); return profile; } + +export async function recordStaffShift(guildId, userId, durationHours) { + const data = await getStaffData(guildId); + const profile = await getStaffProfile(guildId, userId); + profile.activity = profile.activity || {}; + profile.activity.shiftHours = Math.max(0, Number(profile.activity.shiftHours || 0) + Number(durationHours || 0)); + profile.activity.shiftCount = Math.max(0, Number(profile.activity.shiftCount || 0) + 1); + profile.activity.lastShiftHours = Math.max(0, Number(durationHours || 0)); + profile.activity.lastActiveAt = new Date().toISOString(); + data.members[userId] = profile; + await saveStaffData(guildId, data); + return profile; +} + +export async function recordTicketLog(guildId, { messageId, staffId, ticketId, ticketType = null, closedBy = null, closedAt = null }) { + if (!guildId || !messageId || !staffId || !ticketId) return { recorded: false, reason: 'missing_data' }; + const data = await getStaffData(guildId); + if (data.ticketLogMessages[messageId]) return { recorded: false, reason: 'duplicate' }; + const profile = await getStaffProfile(guildId, staffId); + profile.activity = profile.activity || {}; + profile.activity.ticketsHandled = Math.max(0, Number(profile.activity.ticketsHandled || 0) + 1); + profile.activity.lastActiveAt = new Date().toISOString(); + data.members[staffId] = profile; + data.ticketLogMessages[messageId] = { ticketId: String(ticketId), staffId: String(staffId), ticketType, closedBy, closedAt: closedAt || new Date().toISOString(), recordedAt: new Date().toISOString() }; + const entries = Object.entries(data.ticketLogMessages); + if (entries.length > 1000) for (const [oldMessageId] of entries.slice(0, entries.length - 1000)) delete data.ticketLogMessages[oldMessageId]; + await saveStaffData(guildId, data); + return { recorded: true, profile }; +} + +export function calculateActivityScore(profile) { + const activity = profile?.activity || {}; + const moderation = Number(activity.moderationActions || 0); + const tickets = Number(activity.ticketsHandled || 0); + const events = Number(activity.eventsManaged || 0); + const commands = Number(activity.commands || 0); + const messages = Number(activity.messages || 0); + const shiftHours = Number(activity.shiftHours || 0); + return Math.round(Math.min(100, moderation * 0.4 + tickets * 0.35 + events * 5 + commands * 0.05 + messages * 0.01 + shiftHours * 1.5)); +} + +export function countWarnings(profile) { return Array.isArray(profile?.warnings) ? profile.warnings.length : 0; } +export function getStaffLeaderboard(data, limit = 10) { return Object.entries(data?.members || {}).map(([userId, profile]) => ({ userId, profile, score: calculateActivityScore(profile) })).sort((a, b) => b.score - a.score).slice(0, Math.max(1, Math.min(25, Number(limit) || 10))); } diff --git a/src/services/staffShiftService.js b/src/services/staffShiftService.js new file mode 100644 index 0000000000..6cb6d51fe6 --- /dev/null +++ b/src/services/staffShiftService.js @@ -0,0 +1,121 @@ +import { getFromDb, setInDb } from '../utils/database.js'; + +const key = (guildId) => `guild:${guildId}:staff-shifts`; + +const DEFAULTS = { + config: { minimumHours: 0 }, + active: {}, + history: {}, +}; + +function normalize(data) { + const value = data && typeof data === 'object' ? data : {}; + return { + config: { ...DEFAULTS.config, ...(value.config || {}) }, + active: value.active && typeof value.active === 'object' ? value.active : {}, + history: value.history && typeof value.history === 'object' ? value.history : {}, + }; +} + +async function getData(guildId) { + return normalize(await getFromDb(key(guildId), DEFAULTS)); +} + +async function saveData(guildId, data) { + const normalized = normalize(data); + await setInDb(key(guildId), normalized); + return normalized; +} + +export async function getShiftData(guildId) { + return getData(guildId); +} + +export async function updateShiftConfig(guildId, patch) { + const data = await getData(guildId); + data.config = { ...data.config, ...patch }; + return saveData(guildId, data); +} + +export async function getActiveShift(guildId, userId) { + const data = await getData(guildId); + return data.active[userId] || null; +} + +export async function startShift(guildId, userId) { + const data = await getData(guildId); + if (data.active[userId]) return { started: false, reason: 'already_active', shift: data.active[userId] }; + + const shift = { + id: `shift_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + userId: String(userId), + startedAt: new Date().toISOString(), + }; + data.active[userId] = shift; + await saveData(guildId, data); + return { started: true, shift }; +} + +export async function stopShift(guildId, userId) { + const data = await getData(guildId); + const active = data.active[userId]; + if (!active) return { stopped: false, reason: 'not_active' }; + + const endedAt = new Date(); + const startedAt = new Date(active.startedAt); + const durationMs = Math.max(0, endedAt.getTime() - startedAt.getTime()); + const durationHours = durationMs / 3600000; + const record = { + ...active, + endedAt: endedAt.toISOString(), + durationMs, + durationHours: Number(durationHours.toFixed(4)), + }; + + delete data.active[userId]; + data.history[userId] = Array.isArray(data.history[userId]) ? data.history[userId] : []; + data.history[userId].push(record); + if (data.history[userId].length > 500) data.history[userId] = data.history[userId].slice(-500); + await saveData(guildId, data); + return { stopped: true, shift: record }; +} + +export async function getShiftHistory(guildId, userId, limit = 10) { + const data = await getData(guildId); + const history = Array.isArray(data.history[userId]) ? data.history[userId] : []; + return history.slice(-Math.max(1, Math.min(50, Number(limit) || 10))).reverse(); +} + +export function getShiftStats(data, userId) { + const history = Array.isArray(data?.history?.[userId]) ? data.history[userId] : []; + const active = data?.active?.[userId] || null; + const completedMs = history.reduce((sum, shift) => sum + Number(shift.durationMs || 0), 0); + const activeMs = active ? Math.max(0, Date.now() - new Date(active.startedAt).getTime()) : 0; + const totalMs = completedMs + activeMs; + return { + shiftCount: history.length, + completedHours: completedMs / 3600000, + activeHours: activeMs / 3600000, + totalHours: totalMs / 3600000, + active, + lastShift: history.at(-1) || null, + }; +} + +export function getShiftLeaderboard(data, limit = 10) { + const userIds = new Set([ + ...Object.keys(data?.history || {}), + ...Object.keys(data?.active || {}), + ]); + return [...userIds] + .map((userId) => ({ userId, stats: getShiftStats(data, userId) })) + .sort((a, b) => b.stats.totalHours - a.stats.totalHours) + .slice(0, Math.max(1, Math.min(25, Number(limit) || 10))); +} + +export function formatDuration(ms) { + const totalMinutes = Math.max(0, Math.floor(Number(ms || 0) / 60000)); + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + return `${hours}h ${minutes}m`; +} diff --git a/src/services/tempVoiceService.js b/src/services/tempVoiceService.js new file mode 100644 index 0000000000..360f180251 --- /dev/null +++ b/src/services/tempVoiceService.js @@ -0,0 +1,57 @@ +import { ChannelType, PermissionFlagsBits } from 'discord.js'; + +const key = guildId => `guild:${guildId}:tempvoice:config`; + +async function read(client, guildId) { + const value = await client.db.get(key(guildId), null); + return value && typeof value === 'object' ? value : null; +} + +async function write(client, guildId, config) { + await client.db.set(key(guildId), config); + return config; +} + +export async function getTempVoiceConfig(client, guildId) { + return await read(client, guildId) || { + categoryId: null, + triggerChannelId: null, + panelChannelId: null, + panelMessageId: null, + rooms: {}, + }; +} + +export async function saveTempVoiceConfig(client, guildId, config) { + return write(client, guildId, config); +} + +export async function createTempRoom(guild, member, settings) { + const name = `${settings.prefix || '🔊・'}${member.displayName}'s Room`.slice(0, 100); + const channel = await guild.channels.create({ + name, + type: ChannelType.GuildVoice, + parent: settings.categoryId || undefined, + userLimit: settings.userLimit || 0, + bitrate: Math.min(settings.bitrate || 64000, guild.maximumBitrate || 64000), + permissionOverwrites: [ + { id: guild.id, allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.Connect, PermissionFlagsBits.Speak] }, + { id: member.id, allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.Connect, PermissionFlagsBits.Speak, PermissionFlagsBits.MoveMembers, PermissionFlagsBits.ManageChannels] }, + ], + }); + return channel; +} + +export async function removeRoomRecord(client, guildId, channelId) { + const config = await getTempVoiceConfig(client, guildId); + if (config.rooms?.[channelId]) { + delete config.rooms[channelId]; + await saveTempVoiceConfig(client, guildId, config); + } +} + +export async function findOwnedRoom(client, guildId, ownerId) { + const config = await getTempVoiceConfig(client, guildId); + const entry = Object.entries(config.rooms || {}).find(([, room]) => room.ownerId === ownerId); + return entry ? { channelId: entry[0], ...entry[1] } : null; +} diff --git a/src/services/temporaryVoicePanelService.js b/src/services/temporaryVoicePanelService.js new file mode 100644 index 0000000000..79a05cb32a --- /dev/null +++ b/src/services/temporaryVoicePanelService.js @@ -0,0 +1,215 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + EmbedBuilder, + ModalBuilder, + TextInputBuilder, + TextInputStyle, + UserSelectMenuBuilder, + PermissionFlagsBits, + ChannelType, +} from 'discord.js'; +import { getJoinToCreateConfig, getTemporaryChannelInfo } from '../utils/database.js'; +import { logger } from '../utils/logger.js'; + +const PANEL_PREFIX = 'tvp'; + +export function panelCustomId(action, channelId) { + return `${PANEL_PREFIX}:${action}:${channelId}`; +} + +export function buildTemporaryVoicePanel(channel, ownerId) { + const isPublic = channel.permissionsFor(channel.guild.roles.everyone)?.has(PermissionFlagsBits.Connect); + + const embed = new EmbedBuilder() + .setColor('#5865F2') + .setTitle('🎛️ Temporary Voice Panel') + .setDescription( + `Manage **${channel.name}** from this panel.\n\n` + + `👑 **Owner:** <@${ownerId}>\n` + + `👥 **Users:** ${channel.members.size}${channel.userLimit ? `/${channel.userLimit}` : ''}\n` + + `🔐 **Privacy:** ${isPublic ? 'Public' : 'Private'}\n\n` + + 'Only the current room owner can use these controls.' + ) + .setFooter({ text: 'The panel is public • Controls affect this voice room only' }) + .setTimestamp(); + + const row1 = new ActionRowBuilder().addComponents( + new ButtonBuilder().setCustomId(panelCustomId('name', channel.id)).setLabel('Name').setStyle(ButtonStyle.Secondary), + new ButtonBuilder().setCustomId(panelCustomId('limit', channel.id)).setLabel('Limit').setStyle(ButtonStyle.Secondary), + new ButtonBuilder().setCustomId(panelCustomId('privacy', channel.id)).setLabel('Privacy').setStyle(ButtonStyle.Secondary), + new ButtonBuilder().setCustomId(panelCustomId('trust', channel.id)).setLabel('Trust').setStyle(ButtonStyle.Secondary), + new ButtonBuilder().setCustomId(panelCustomId('untrust', channel.id)).setLabel('Untrust').setStyle(ButtonStyle.Secondary), + ); + + const row2 = new ActionRowBuilder().addComponents( + new ButtonBuilder().setCustomId(panelCustomId('invite', channel.id)).setLabel('Invite').setStyle(ButtonStyle.Secondary), + new ButtonBuilder().setCustomId(panelCustomId('kick', channel.id)).setLabel('Kick').setStyle(ButtonStyle.Secondary), + new ButtonBuilder().setCustomId(panelCustomId('block', channel.id)).setLabel('Block').setStyle(ButtonStyle.Secondary), + new ButtonBuilder().setCustomId(panelCustomId('unblock', channel.id)).setLabel('Unblock').setStyle(ButtonStyle.Secondary), + new ButtonBuilder().setCustomId(panelCustomId('transfer', channel.id)).setLabel('Transfer').setStyle(ButtonStyle.Secondary), + ); + + const row3 = new ActionRowBuilder().addComponents( + new ButtonBuilder().setCustomId(panelCustomId('delete', channel.id)).setLabel('Delete').setStyle(ButtonStyle.Danger), + ); + + return { embeds: [embed], components: [row1, row2, row3] }; +} + +export function buildUserSelect(action, channelId, placeholder) { + const select = new UserSelectMenuBuilder() + .setCustomId(panelCustomId(`select:${action}`, channelId)) + .setPlaceholder(placeholder) + .setMinValues(1) + .setMaxValues(1); + return new ActionRowBuilder().addComponents(select); +} + +export function buildNameModal(channelId, currentName) { + const input = new TextInputBuilder() + .setCustomId('name') + .setLabel('Room name') + .setStyle(TextInputStyle.Short) + .setMinLength(1) + .setMaxLength(100) + .setRequired(true) + .setValue(currentName); + return new ModalBuilder() + .setCustomId(panelCustomId('modal:name', channelId)) + .setTitle('Change Room Name') + .addComponents(new ActionRowBuilder().addComponents(input)); +} + +export function buildLimitModal(channelId, currentLimit) { + const input = new TextInputBuilder() + .setCustomId('limit') + .setLabel('User limit (0 = unlimited)') + .setStyle(TextInputStyle.Short) + .setMinLength(1) + .setMaxLength(2) + .setRequired(true) + .setValue(String(currentLimit || 0)); + return new ModalBuilder() + .setCustomId(panelCustomId('modal:limit', channelId)) + .setTitle('Change User Limit') + .addComponents(new ActionRowBuilder().addComponents(input)); +} + +export async function getTemporaryOwner(client, guildId, channelId) { + const info = await getTemporaryChannelInfo(client, guildId, channelId); + return info?.ownerId || null; +} + +export async function isTemporaryOwner(interaction, client, channelId) { + if (!interaction.guild || !interaction.member) return false; + const ownerId = await getTemporaryOwner(client, interaction.guild.id, channelId); + return ownerId === interaction.user.id; +} + +async function getConfig(client, guildId) { + return getJoinToCreateConfig(client, guildId); +} + +async function saveConfig(client, guildId, config) { + await client.db.set(`guild:${guildId}:jointocreate`, config); +} + +export async function updatePanel(client, channel) { + try { + const info = await getTemporaryChannelInfo(client, channel.guild.id, channel.id); + if (!info) return null; + + const config = await getConfig(client, channel.guild.id); + const stored = config?.temporaryChannels?.[channel.id]; + let message = null; + + if (stored?.panelMessageId) { + message = await channel.messages.fetch(stored.panelMessageId).catch(() => null); + } + + if (message) { + await message.edit(buildTemporaryVoicePanel(channel, info.ownerId)); + return message; + } + + if (!channel.isSendable?.()) return null; + message = await channel.send(buildTemporaryVoicePanel(channel, info.ownerId)); + + if (config?.temporaryChannels?.[channel.id]) { + config.temporaryChannels[channel.id].panelMessageId = message.id; + await saveConfig(client, channel.guild.id, config); + } + + return message; + } catch (error) { + logger.warn(`Failed to sync temporary voice panel for ${channel?.id}: ${error.message}`); + return null; + } +} + +export async function togglePrivacy(client, channel) { + const everyone = channel.guild.roles.everyone; + const currentlyPublic = channel.permissionsFor(everyone)?.has(PermissionFlagsBits.Connect); + await channel.permissionOverwrites.edit(everyone, { Connect: !currentlyPublic }); + return !currentlyPublic; +} + +export async function trustUser(channel, userId) { + await channel.permissionOverwrites.edit(userId, { + Connect: true, + Speak: true, + }); +} + +export async function blockUser(channel, userId) { + await channel.permissionOverwrites.edit(userId, { + Connect: false, + Speak: false, + }); +} + +export async function clearUserOverride(channel, userId) { + await channel.permissionOverwrites.delete(userId).catch(() => null); +} + +export async function transferOwnership(client, channel, newOwnerId) { + const guildId = channel.guild.id; + const config = await getConfig(client, guildId); + const info = config?.temporaryChannels?.[channel.id]; + if (!info) throw new Error('Temporary channel information was not found.'); + + info.ownerId = newOwnerId; + info.panelMessageId = info.panelMessageId || null; + await saveConfig(client, guildId, config); + + const newOwner = await channel.guild.members.fetch(newOwnerId); + const safeName = `${newOwner.displayName || newOwner.user.username}'s Room`.slice(0, 100); + await channel.setName(safeName || 'Voice Room'); + + return newOwner; +} + +export async function kickUser(channel, userId) { + const member = await channel.guild.members.fetch(userId).catch(() => null); + if (!member || member.voice.channelId !== channel.id) return false; + await member.voice.disconnect('Removed by temporary room owner'); + return true; +} + +export async function deleteTemporaryRoom(client, channel) { + const guildId = channel.guild.id; + const config = await getConfig(client, guildId); + if (config?.temporaryChannels?.[channel.id]) { + delete config.temporaryChannels[channel.id]; + await saveConfig(client, guildId, config); + } + await channel.delete('Temporary voice room deleted by owner'); +} + +export async function createRoomInvite(channel) { + return channel.createInvite({ maxAge: 0, maxUses: 0, unique: true }); +} + +export { ChannelType }; diff --git a/src/utils/constants.js b/src/utils/constants.js index 18382d6225..b0da0c1d45 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -30,6 +30,12 @@ export const DEFAULT_GUILD_CONFIG = { adminRole: null, welcomeChannel: null, autoRole: null, + autoReplies: [], + autoReaction: { + enabled: false, + channelId: null, + reaction: null, + }, logging: { enabled: false, channels: { audit: null, applications: null, reports: null }, @@ -42,9 +48,9 @@ export const DEFAULT_GUILD_CONFIG = { }; export const INTERACTION_TIMEOUTS = { - EXPIRE: 15 * 60 * 1000, - DEFER_TIMEOUT: 3000, - REPLY_TIMEOUT: 3000 + EXPIRE: 15 * 60 * 1000, + DEFER_TIMEOUT: 3000, + REPLY_TIMEOUT: 3000 }; export const STORAGE_LIMITS = { @@ -94,4 +100,4 @@ export default { DEFAULTS, ERROR_DEFAULTS, TIME -}; \ No newline at end of file +}; diff --git a/src/utils/economy.js b/src/utils/economy.js deleted file mode 100644 index c50e9ab1f3..0000000000 --- a/src/utils/economy.js +++ /dev/null @@ -1,399 +0,0 @@ -// economy.js - -import { getColor, getEconomyKey as getEconomyStorageKey } from './database.js'; -import { BotConfig } from '../config/bot.js'; -import { normalizeEconomyData } from './schemas.js'; -import { logger } from './logger.js'; -import { validateDiscordId, validateNumber } from './validation.js'; -import { DEFAULT_ECONOMY_DATA } from './constants.js'; -import { createError, ErrorTypes, wrapServiceBoundary } from './errorHandler.js'; - -const ECONOMY_CONFIG = BotConfig.economy || {}; -const BASE_BANK_CAPACITY = ECONOMY_CONFIG.baseBankCapacity || 10000; -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 getEconomyStorageKey(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) { - const currencyName = ECONOMY_CONFIG.currency?.name || 'coins'; - return `${amount.toLocaleString()} ${currencyName}`; -} - -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, {}); - const defaults = { - ...DEFAULT_ECONOMY_DATA, - wallet: ECONOMY_CONFIG.startingBalance ?? DEFAULT_ECONOMY_DATA.wallet, - }; - - return normalizeEconomyData(data, defaults); - } 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 const addMoney = wrapServiceBoundary(async function addMoney(client, guildId, userId, amount, type = 'wallet') { - const validAmount = validateNumber(amount, 'amount'); - if (validAmount === null || validAmount <= 0) { - throw createError( - 'Invalid amount', - ErrorTypes.VALIDATION, - 'Amount must be a positive number.', - { guildId, userId, amount, operation: 'addMoney' } - ); - } - - if (type !== 'wallet' && type !== 'bank') { - throw createError( - 'Invalid money type', - ErrorTypes.VALIDATION, - 'Type must be "wallet" or "bank".', - { guildId, userId, type, operation: 'addMoney' } - ); - } - - const userData = await getEconomyData(client, guildId, userId); - - if (type === 'bank') { - const maxBank = getMaxBankCapacity(userData); - if ((userData.bank || 0) + validAmount > maxBank) { - throw createError( - 'Bank capacity exceeded', - ErrorTypes.VALIDATION, - `Bank capacity exceeded. Current: ${userData.bank || 0}, Max: ${maxBank}.`, - { guildId, userId, current: userData.bank || 0, max: maxBank, operation: 'addMoney' } - ); - } - userData.bank = (userData.bank || 0) + validAmount; - } else { - userData.wallet = (userData.wallet || 0) + validAmount; - } - - await setEconomyData(client, guildId, userId, userData); - - return { - newBalance: type === 'bank' ? userData.bank : userData.wallet, - ...(type === 'bank' ? { maxBank: getMaxBankCapacity(userData) } : {}), - }; -}, { - service: 'economy', - operation: 'addMoney', - userMessage: 'Failed to add money. Please try again.', -}); - -export const removeMoney = wrapServiceBoundary(async function removeMoney(client, guildId, userId, amount, type = 'wallet') { - const validAmount = validateNumber(amount, 'amount'); - if (validAmount === null || validAmount <= 0) { - throw createError( - 'Invalid amount', - ErrorTypes.VALIDATION, - 'Amount must be a positive number.', - { guildId, userId, amount, operation: 'removeMoney' } - ); - } - - if (type !== 'wallet' && type !== 'bank') { - throw createError( - 'Invalid money type', - ErrorTypes.VALIDATION, - 'Type must be "wallet" or "bank".', - { guildId, userId, type, operation: 'removeMoney' } - ); - } - - const userData = await getEconomyData(client, guildId, userId); - - if (type === 'bank') { - if ((userData.bank || 0) < validAmount) { - throw createError( - 'Insufficient bank funds', - ErrorTypes.VALIDATION, - `Insufficient funds in bank. You have ${userData.bank || 0}, need ${validAmount}.`, - { guildId, userId, current: userData.bank || 0, required: validAmount, operation: 'removeMoney' } - ); - } - userData.bank = (userData.bank || 0) - validAmount; - } else { - if ((userData.wallet || 0) < validAmount) { - throw createError( - 'Insufficient wallet funds', - ErrorTypes.VALIDATION, - `Insufficient funds in wallet. You have ${userData.wallet || 0}, need ${validAmount}.`, - { guildId, userId, current: userData.wallet || 0, required: validAmount, operation: 'removeMoney' } - ); - } - userData.wallet = (userData.wallet || 0) - validAmount; - } - - await setEconomyData(client, guildId, userId, userData); - - return { - newBalance: type === 'bank' ? userData.bank : userData.wallet, - }; -}, { - service: 'economy', - operation: 'removeMoney', - userMessage: 'Failed to remove money. Please try again.', -}); - -export function getShopInventory() { - return [ - { - 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' - } - ]; -} \ No newline at end of file diff --git a/src/utils/partner.js b/src/utils/partner.js new file mode 100644 index 0000000000..b10f59c256 --- /dev/null +++ b/src/utils/partner.js @@ -0,0 +1,88 @@ +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ChannelType, EmbedBuilder, PermissionFlagsBits } from 'discord.js'; + +const key = guildId => `partners:${guildId}`; +async function load(client, guildId) { const data = await client.db.get(key(guildId), {}); return data && typeof data === 'object' ? data : {}; } +async function save(client, guildId, data) { await client.db.set(key(guildId), data); } + +export async function getPartnerData(client, guildId) { + const data = await load(client, guildId); + data.counter = Number(data.counter || 0); + data.applications = Array.isArray(data.applications) ? data.applications : []; + data.partners = Array.isArray(data.partners) ? data.partners : []; + const storedMin = Number(data.requirements?.minMembers); + // 500 was the old hard-coded default. Migrate that legacy value to the new default of 20. + const minMembers = !Number.isFinite(storedMin) || storedMin === 500 ? 20 : Math.max(20, storedMin); + data.requirements = { minMembers, requireInvite: data.requirements?.requireInvite !== false, requireActive: data.requirements?.requireActive !== false }; + if (storedMin === 500) await save(client, guildId, data); + return data; +} +export async function savePartnerData(client, guildId, data) { return save(client, guildId, data); } + +export async function setupPartnerPanel(interaction, announcementChannel) { + const guild = interaction.guild; + if (!guild) return interaction.reply({ content: 'هذا الأمر يعمل داخل السيرفر فقط.', ephemeral: true }); + if (!announcementChannel || announcementChannel.type !== ChannelType.GuildText) return interaction.reply({ content: 'اختر روم نصي صالح للإعلانات.', ephemeral: true }); + await interaction.deferReply({ ephemeral: true }); + const data = await getPartnerData(interaction.client, guild.id); + let panelChannel = data.panelChannelId ? guild.channels.cache.get(data.panelChannelId) : null; + if (!panelChannel) panelChannel = guild.channels.cache.find(c => c.type === ChannelType.GuildText && c.name === 'partnerships'); + if (!panelChannel) panelChannel = await guild.channels.create({ name: 'partnerships', type: ChannelType.GuildText, reason: 'Partner system setup', permissionOverwrites: [{ id: guild.roles.everyone.id, allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.ReadMessageHistory], deny: [PermissionFlagsBits.SendMessages] }] }); + let requestChannel = data.requestChannelId ? guild.channels.cache.get(data.requestChannelId) : null; + if (!requestChannel) requestChannel = guild.channels.cache.find(c => c.type === ChannelType.GuildText && c.name === 'partnership-requests'); + if (!requestChannel) requestChannel = await guild.channels.create({ name: 'partnership-requests', type: ChannelType.GuildText, reason: 'Partner system request channel', permissionOverwrites: [{ id: guild.roles.everyone.id, allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.ReadMessageHistory], deny: [PermissionFlagsBits.SendMessages] }] }); + + const embed = new EmbedBuilder().setColor(0x5865f2).setTitle('🤝 شراكات السيرفر') + .setDescription('حاب تسوي شراكة مع سيرفرنا؟ اضغط **تقديم طلب شراكة** وأرسل بيانات سيرفرك.\n\n**الشروط:**\n• 20 عضو أو أكثر\n• رابط دعوة صالح\n• سيرفر نشط\n• لا توجد مخالفات خطيرة حديثة') + .setFooter({ text: `${guild.name} • نظام الشراكات` }); + const row = new ActionRowBuilder().addComponents(new ButtonBuilder().setCustomId('partner_apply').setLabel('تقديم طلب شراكة').setEmoji('🤝').setStyle(ButtonStyle.Primary)); + let panelMessage = data.panelMessageId ? await panelChannel.messages.fetch(data.panelMessageId).catch(() => null) : null; + if (panelMessage) await panelMessage.edit({ embeds: [embed], components: [row] }); + else panelMessage = await panelChannel.send({ embeds: [embed], components: [row] }); + data.panelChannelId = panelChannel.id; + data.panelMessageId = panelMessage.id; + data.requestChannelId = requestChannel.id; + data.announcementChannelId = announcementChannel.id; + await savePartnerData(interaction.client, guild.id, data); + return interaction.editReply({ content: `✅ تم تجهيز نظام الشراكات.\nلوحة الطلبات: ${panelChannel}\nروم الطلبات: ${requestChannel}\nروم إعلانات الشراكات: ${announcementChannel}` }); +} + +export async function partnerDashboard(interaction) { + const data = await getPartnerData(interaction.client, interaction.guildId); + const pending = data.applications.filter(a => a.status === 'pending').length; + const active = data.partners.filter(p => p.status === 'active').length; + const embed = new EmbedBuilder().setColor(0x5865f2).setTitle('🤝 إدارة الشراكات').setDescription('إدارة طلبات الشراكة والشراكات الحالية من هنا.').addFields( + { name: 'الشراكات الحالية', value: String(active), inline: true }, + { name: 'الطلبات المعلقة', value: String(pending), inline: true }, + { name: 'إجمالي الطلبات', value: String(data.applications.length), inline: true }, + { name: 'الحد الأدنى', value: `${data.requirements.minMembers} عضوًا`, inline: true }, + ); + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder().setCustomId('partner_pending').setLabel('الطلبات').setEmoji('🟡').setStyle(ButtonStyle.Primary), + new ButtonBuilder().setCustomId('partner_active').setLabel('الشركاء').setEmoji('🤝').setStyle(ButtonStyle.Success), + new ButtonBuilder().setCustomId('partner_stats').setLabel('الإحصائيات').setEmoji('📊').setStyle(ButtonStyle.Secondary), + new ButtonBuilder().setCustomId('partner_settings').setLabel('الإعدادات').setEmoji('⚙️').setStyle(ButtonStyle.Secondary), + ); + return interaction.reply({ embeds: [embed], components: [row], ephemeral: true }); +} + +export function applicationEmbed(app) { + const status = app.status === 'accepted' ? '🟢 مقبول' : app.status === 'rejected' ? '🔴 مرفوض' : '🟡 قيد المراجعة'; + return new EmbedBuilder().setColor(app.status === 'accepted' ? 0x57f287 : app.status === 'rejected' ? 0xed4245 : 0x5865f2).setTitle(`🤝 طلب شراكة #${app.id}`).addFields( + { name: 'السيرفر', value: app.serverName, inline: true }, + { name: 'عدد الأعضاء', value: String(app.members), inline: true }, + { name: 'الحالة', value: status, inline: true }, + { name: 'رابط الدعوة', value: app.invite }, + { name: 'مقدم الطلب', value: `<@${app.applicantId}>`, inline: true }, + { name: 'وصف السيرفر', value: app.description || 'لا يوجد وصف.' }, + { name: 'تاريخ الطلب', value: `` }, + ...(app.reviewedBy ? [{ name: 'تمت المراجعة بواسطة', value: `<@${app.reviewedBy}>`, inline: true }] : []), + ); +} + +export function applicationButtons(app) { + if (app.status !== 'pending') return []; + return [new ActionRowBuilder().addComponents( + new ButtonBuilder().setCustomId(`partner_accept:${app.id}`).setLabel('قبول').setStyle(ButtonStyle.Success), + new ButtonBuilder().setCustomId(`partner_reject:${app.id}`).setLabel('رفض').setStyle(ButtonStyle.Danger), + )]; +} diff --git a/src/utils/suggestions.js b/src/utils/suggestions.js new file mode 100644 index 0000000000..038ae72ae9 --- /dev/null +++ b/src/utils/suggestions.js @@ -0,0 +1,75 @@ +import { ChannelType, PermissionFlagsBits, ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from 'discord.js'; + +const key = guildId => `suggestions:${guildId}`; + +async function load(client, guildId) { + const data = await client.db.get(key(guildId), {}); + return data && typeof data === 'object' ? data : {}; +} + +async function save(client, guildId, data) { + await client.db.set(key(guildId), data); +} + +export async function setupSuggestions(interaction) { + const guild = interaction.guild; + const existing = await load(interaction.client, guild.id); + let channel = existing.channelId ? guild.channels.cache.get(existing.channelId) : null; + if (!channel) { + channel = await guild.channels.create({ + name: 'suggestions', + type: ChannelType.GuildText, + reason: 'Suggestions system setup', + permissionOverwrites: [ + { id: guild.id, allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.ReadMessageHistory], deny: [PermissionFlagsBits.SendMessages] }, + ], + }); + } + const embed = new EmbedBuilder() + .setTitle('💡 Suggestions') + .setDescription('Have an idea for the server? Submit it below and let the community vote.') + .addFields({ name: 'How it works', value: 'Click **Submit Suggestion**, write your idea, then the community can vote on it.' }); + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder().setCustomId('suggestions_submit').setLabel('Submit Suggestion').setEmoji('💡').setStyle(ButtonStyle.Primary), + ); + if (existing.panelMessageId) { + const old = await channel.messages.fetch(existing.panelMessageId).catch(() => null); + if (old) await old.edit({ embeds: [embed], components: [row] }); + else existing.panelMessageId = null; + } + if (!existing.panelMessageId) { + const message = await channel.send({ embeds: [embed], components: [row] }); + existing.panelMessageId = message.id; + } + existing.channelId = channel.id; + existing.counter = Number(existing.counter || 0); + await save(interaction.client, guild.id, existing); + return interaction.reply({ content: `✅ Suggestions system is ready in ${channel}.`, ephemeral: true }); +} + +export async function getSuggestions(client, guildId) { return load(client, guildId); } +export async function saveSuggestions(client, guildId, data) { return save(client, guildId, data); } +export async function nextSuggestionId(client, guildId) { + const data = await load(client, guildId); + data.counter = Number(data.counter || 0) + 1; + await save(client, guildId, data); + return data.counter; +} + +export function suggestionEmbed(s) { + const status = { pending: '🟡 Pending', accepted: '🟢 Accepted', rejected: '🔴 Rejected', considered: '🔵 Considered', closed: '⚫ Closed' }[s.status] || '🟡 Pending'; + return new EmbedBuilder().setTitle(`💡 Suggestion #${s.id}`).setDescription(s.text).addFields( + { name: 'Author', value: `<@${s.authorId}>`, inline: true }, + { name: 'Status', value: status, inline: true }, + { name: 'Votes', value: `👍 ${s.upvotes.length} • 👎 ${s.downvotes.length}`, inline: true }, + ).setTimestamp(new Date(s.createdAt)); +} + +export function suggestionButtons(s) { + return [new ActionRowBuilder().addComponents( + new ButtonBuilder().setCustomId(`suggestions_up:${s.id}`).setLabel(`👍 ${s.upvotes.length}`).setStyle(ButtonStyle.Success), + new ButtonBuilder().setCustomId(`suggestions_down:${s.id}`).setLabel(`👎 ${s.downvotes.length}`).setStyle(ButtonStyle.Danger), + new ButtonBuilder().setCustomId(`suggestions_accept:${s.id}`).setLabel('Accept').setStyle(ButtonStyle.Success), + new ButtonBuilder().setCustomId(`suggestions_reject:${s.id}`).setLabel('Reject').setStyle(ButtonStyle.Danger), + )]; +}