From 299c32a122724b83a491f05c8fd513141766a01e Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Fri, 19 Jun 2026 21:26:57 -0600 Subject: [PATCH 001/115] Update bot.js --- src/config/bot.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/bot.js b/src/config/bot.js index 989657a1b..b7772e0ff 100644 --- a/src/config/bot.js +++ b/src/config/bot.js @@ -24,7 +24,7 @@ export const botConfig = { activities: [ { // Text users will see (example: "Playing /help | Titan Bot"). - name: "Made with ❤️", + name: "Made By Bemzy And KJ", // Activity type number (0 = Playing). type: 0, }, From ae7cd6b604e34c74b1449d9c110cb2fbfaf26136 Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Fri, 19 Jun 2026 22:25:07 -0600 Subject: [PATCH 002/115] Update messageCreate.js --- src/events/messageCreate.js | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/events/messageCreate.js b/src/events/messageCreate.js index d26280671..4786cebb7 100644 --- a/src/events/messageCreate.js +++ b/src/events/messageCreate.js @@ -34,6 +34,8 @@ export default { return; } + await handleFAQ(message); + await handlePrefixCommand(message, client); await handleLeveling(message, client); @@ -43,6 +45,29 @@ export default { } }; +async function handleFAQ(message) { + const faqs = { + "how do i get started": "Welcome! Check out our rules and grab your roles to get started!", + "how do i make a ticket": "Use the `/ticket` command to open a support ticket!", + "what are the rules": "Please check the rules channel for our server rules!", + "how do i level up": "Send messages in the server to earn XP and level up!", + "what commands are available": "Type `/` to see all available commands!", + "how do i get roles": "Head over to the roles channel and pick the ones you want!", + "who made this bot": "This bot was built with TeamSyne — a powerful all-in-one Discord assistant!", + }; + + const content = message.content.toLowerCase(); + + for (const [keyword, reply] of Object.entries(faqs)) { + if (content.includes(keyword)) { + await message.reply(reply); + return true; + } + } + + return false; +} + async function handlePrefixCommand(message, client) { try { const guildConfig = await getGuildConfig(client, message.guild.id); From 1d1e262abdf416ae25363e3fe2bb5657e80d1e99 Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Fri, 19 Jun 2026 22:51:38 -0600 Subject: [PATCH 003/115] Updated new command --- src/commands/Moderation/punish.js | 287 ++++++++++++++++++ src/events/interactionCreate.js | 20 +- src/interactions/buttons/punish_processed.js | 54 ++++ src/interactions/buttons/punish_reviewed.js | 63 ++++ src/interactions/buttons/punish_roster.js | 54 ++++ src/interactions/buttons/punish_rosterlink.js | 54 ++++ 6 files changed, 531 insertions(+), 1 deletion(-) create mode 100644 src/commands/Moderation/punish.js create mode 100644 src/interactions/buttons/punish_processed.js create mode 100644 src/interactions/buttons/punish_reviewed.js create mode 100644 src/interactions/buttons/punish_roster.js create mode 100644 src/interactions/buttons/punish_rosterlink.js diff --git a/src/commands/Moderation/punish.js b/src/commands/Moderation/punish.js new file mode 100644 index 000000000..0dc9d0643 --- /dev/null +++ b/src/commands/Moderation/punish.js @@ -0,0 +1,287 @@ +import { + SlashCommandBuilder, + PermissionFlagsBits, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + EmbedBuilder, +} from 'discord.js'; +import { logModerationAction, generateCaseId, storeModerationCase } from '../../utils/moderation.js'; +import { logger } from '../../utils/logger.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; + +const PUNISHMENT_LOG_CHANNEL_ID = '1517145309015314442'; + +const PUNISHMENT_TYPES = [ + 'Verbal Warning', + 'Written Warning', + 'Mute/Timeout', + 'Kick', + 'Temporary Ban', + 'Permanent Ban', + 'Termination', + 'Demotion', + 'Suspension', +]; + +function generateCaseCode() { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + return Array.from({ length: 8 }, () => chars[Math.floor(Math.random() * chars.length)]).join(''); +} + +function parseDuration(durationStr) { + if (!durationStr) return null; + const match = durationStr.match(/^(\d+)\s*(s|m|h|d|w)$/i); + if (!match) return null; + const value = parseInt(match[1]); + const unit = match[2].toLowerCase(); + const multipliers = { s: 1000, m: 60000, h: 3600000, d: 86400000, w: 604800000 }; + return value * multipliers[unit]; +} + +function formatDuration(durationStr) { + if (!durationStr) return null; + const match = durationStr.match(/^(\d+)\s*(s|m|h|d|w)$/i); + if (!match) return durationStr; + const value = parseInt(match[1]); + const unit = match[2].toLowerCase(); + const labels = { s: 'Second', m: 'Minute', h: 'Hour', d: 'Day', w: 'Week' }; + return `${value} ${labels[unit]}${value !== 1 ? 's' : ''}`; +} + +export default { + data: new SlashCommandBuilder() + .setName('punish') + .setDescription('Issue a punishment and log it to the punishment channel') + .addUserOption(option => + option.setName('member').setDescription('The member to punish').setRequired(true) + ) + .addStringOption(option => + option + .setName('type') + .setDescription('Type of punishment') + .setRequired(true) + .addChoices( + ...PUNISHMENT_TYPES.map(t => ({ name: t, value: t })) + ) + ) + .addStringOption(option => + option.setName('reason').setDescription('Reason for the punishment').setRequired(true) + ) + .addStringOption(option => + option + .setName('duration') + .setDescription('Duration (e.g. 30d, 7d, 24h, 1w) — leave blank for permanent') + .setRequired(false) + ) + .addAttachmentOption(option => + option.setName('evidence1').setDescription('Evidence image #1').setRequired(false) + ) + .addAttachmentOption(option => + option.setName('evidence2').setDescription('Evidence image #2').setRequired(false) + ) + .addAttachmentOption(option => + option.setName('evidence3').setDescription('Evidence image #3').setRequired(false) + ) + .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers), + + category: 'moderation', + + async execute(interaction, config, client) { + try { + await interaction.deferReply({ ephemeral: true }); + + const member = interaction.options.getMember('member'); + const user = interaction.options.getUser('member'); + const punishmentType = interaction.options.getString('type'); + const reason = interaction.options.getString('reason'); + const durationStr = interaction.options.getString('duration'); + const evidence1 = interaction.options.getAttachment('evidence1'); + const evidence2 = interaction.options.getAttachment('evidence2'); + const evidence3 = interaction.options.getAttachment('evidence3'); + + if (!user) { + throw new TitanBotError('Missing target member', ErrorTypes.USER_INPUT, 'You must specify a member to punish.', { subtype: 'invalid_user' }); + } + + if (user.id === interaction.user.id) { + throw new TitanBotError('Self punishment', ErrorTypes.USER_INPUT, 'You cannot punish yourself.', { subtype: 'self_action' }); + } + + if (user.id === client.user.id) { + throw new TitanBotError('Bot punishment', ErrorTypes.USER_INPUT, 'You cannot punish the bot.', { subtype: 'bot_action' }); + } + + // Validate duration format if provided + if (durationStr && !parseDuration(durationStr)) { + throw new TitanBotError('Invalid duration', ErrorTypes.USER_INPUT, 'Invalid duration format. Use formats like `30d`, `7d`, `24h`, `1w`, `30m`.', { subtype: 'invalid_duration' }); + } + + const caseCode = generateCaseCode(); + const now = new Date(); + const formattedDate = now.toLocaleDateString('en-US', { + weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', + hour: '2-digit', minute: '2-digit', timeZoneName: 'short', + }); + + // Calculate expiry if duration provided + let expiresText = null; + if (durationStr) { + const ms = parseDuration(durationStr); + const expiryDate = new Date(now.getTime() + ms); + expiresText = expiryDate.toLocaleDateString('en-US', { + weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', + hour: '2-digit', minute: '2-digit', timeZoneName: 'short', + }); + } + + const evidenceAttachments = [evidence1, evidence2, evidence3].filter(Boolean); + + // Build the punishment embed + const embed = new EmbedBuilder() + .setTitle(`Punishment Log - Case \`${caseCode}\``) + .setColor(0xE74C3C) + .addFields( + { + name: 'Member', + value: `<@${user.id}> (\`${user.id}\`)`, + inline: false, + }, + { + name: 'Issued by', + value: `<@${interaction.user.id}> (\`${interaction.user.id}\`)`, + inline: false, + }, + { + name: 'Issued', + value: formattedDate, + inline: false, + }, + { + name: 'Punishment Issued', + value: `**${punishmentType.toUpperCase()}**`, + inline: false, + }, + { + name: 'Reason', + value: reason, + inline: false, + }, + ) + .setThumbnail(user.displayAvatarURL({ dynamic: true, size: 128 })) + .setTimestamp(); + + if (durationStr) { + embed.addFields( + { name: 'Active For', value: formatDuration(durationStr), inline: true }, + { name: 'Expires', value: expiresText, inline: true }, + ); + } else { + embed.addFields({ name: 'Duration', value: 'Permanent', inline: false }); + } + + if (evidenceAttachments.length > 0) { + embed.addFields({ + name: 'Evidence', + value: evidenceAttachments.map((att, i) => `[Image ${i + 1}](${att.url})`).join(' · '), + inline: false, + }); + // Set first image as the embed image + embed.setImage(evidenceAttachments[0].url); + } + + // Build status buttons + const buttons = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`punish_reviewed_${caseCode}`) + .setLabel('✅ Reviewed by IA/HC') + .setStyle(ButtonStyle.Success), + new ButtonBuilder() + .setCustomId(`punish_processed_${caseCode}`) + .setLabel('Department Hub Processed') + .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId(`punish_roster_${caseCode}`) + .setLabel('Roles & Roster Updated') + .setStyle(ButtonStyle.Danger), + new ButtonBuilder() + .setCustomId(`punish_rosterlink_${caseCode}`) + .setLabel('Roster') + .setStyle(ButtonStyle.Secondary), + ); + + // Send to punishment log channel + const logChannel = interaction.guild.channels.cache.get(PUNISHMENT_LOG_CHANNEL_ID); + if (!logChannel) { + throw new TitanBotError('Log channel not found', ErrorTypes.CONFIGURATION, 'Punishment log channel not found. Please check the channel ID.', { subtype: 'missing_channel' }); + } + + const logMessage = await logChannel.send({ + embeds: [embed], + components: [buttons], + files: evidenceAttachments.length > 1 + ? evidenceAttachments.slice(1).map(att => att.url) + : [], + }); + + // Store the case in the database + await storeModerationCase({ + guildId: interaction.guild.id, + caseId: caseCode, + caseData: { + action: punishmentType, + target: `${user.tag} (${user.id})`, + executor: `${interaction.user.tag} (${interaction.user.id})`, + reason, + duration: durationStr ? formatDuration(durationStr) : 'Permanent', + metadata: { + userId: user.id, + moderatorId: interaction.user.id, + caseCode, + messageId: logMessage.id, + channelId: PUNISHMENT_LOG_CHANNEL_ID, + evidenceUrls: evidenceAttachments.map(a => a.url), + }, + }, + }); + + // Log to audit system + await logModerationAction({ + client, + guild: interaction.guild, + event: { + action: punishmentType, + target: `${user.tag} (${user.id})`, + executor: `${interaction.user.tag} (${interaction.user.id})`, + reason, + duration: durationStr ? formatDuration(durationStr) : null, + caseId: caseCode, + metadata: { + userId: user.id, + moderatorId: interaction.user.id, + }, + }, + }); + + await InteractionHelper.universalReply(interaction, { + embeds: [ + new EmbedBuilder() + .setColor(0x2ECC71) + .setTitle('✅ Punishment Logged') + .setDescription(`Case \`${caseCode}\` has been created and logged to <#${PUNISHMENT_LOG_CHANNEL_ID}>.`) + .addFields( + { name: 'Member', value: `<@${user.id}>`, inline: true }, + { name: 'Type', value: punishmentType, inline: true }, + { name: 'Reason', value: reason, inline: false }, + ) + .setTimestamp(), + ], + }); + + } catch (error) { + logger.error('Punish command error:', error); + await handleInteractionError(interaction, error, { subtype: 'punish_failed' }); + } + }, +}; diff --git a/src/events/interactionCreate.js b/src/events/interactionCreate.js index 055981a66..e9df9fb94 100644 --- a/src/events/interactionCreate.js +++ b/src/events/interactionCreate.js @@ -266,6 +266,25 @@ export default { return; } + if (interaction.customId.startsWith('punish_')) { + const parts = interaction.customId.split('_'); + const buttonType = `${parts[0]}_${parts[1]}`; // e.g. "punish_reviewed" + const button = client.buttons.get(buttonType); + + if (button) { + try { + await button.execute(interaction, client, []); + } catch (error) { + await handleInteractionError(interaction, error, withTraceContext({ + type: 'button', + customId: interaction.customId, + handler: 'punishment' + }, interactionTraceContext)); + } + } + return; + } + const [customId, ...args] = interaction.customId.split(':'); const button = client.buttons.get(customId); @@ -361,7 +380,6 @@ export default { if (!modal) { if (!interaction.customId.includes(':')) { - return; } diff --git a/src/interactions/buttons/punish_processed.js b/src/interactions/buttons/punish_processed.js new file mode 100644 index 000000000..739bec2a7 --- /dev/null +++ b/src/interactions/buttons/punish_processed.js @@ -0,0 +1,54 @@ +// src/buttons/punish_processed.js +import { EmbedBuilder } from 'discord.js'; +import { logger } from '../utils/logger.js'; + +const BUTTON_LABELS = { + punish_reviewed: '✅ Reviewed by IA/HC', + punish_processed: '🏢 Department Hub Processed', + punish_roster: '🔄 Roles & Roster Updated', + punish_rosterlink: '📋 Roster', +}; + +async function execute(interaction, client, args) { + try { + const customId = interaction.customId; + const buttonType = Object.keys(BUTTON_LABELS).find(key => customId.startsWith(key)); + if (!buttonType) return; + + const caseCode = customId.replace(`${buttonType}_`, ''); + const label = BUTTON_LABELS[buttonType]; + + const originalEmbed = interaction.message.embeds[0]; + if (!originalEmbed) return; + + const updatedEmbed = EmbedBuilder.from(originalEmbed); + + const alreadySet = (updatedEmbed.data.fields || []).some(f => f.name === label); + if (alreadySet) { + await interaction.reply({ content: 'This status has already been marked.', ephemeral: true }); + return; + } + + const timestamp = new Date().toLocaleDateString('en-US', { + month: 'short', day: 'numeric', year: 'numeric', + hour: '2-digit', minute: '2-digit', + }); + + updatedEmbed.addFields({ + name: label, + value: `By <@${interaction.user.id}> • ${timestamp}`, + inline: false, + }); + + await interaction.message.edit({ embeds: [updatedEmbed] }); + await interaction.reply({ + content: `✅ Marked **${label}** for case \`${caseCode}\`.`, + ephemeral: true, + }); + } catch (error) { + logger.error('Error handling punishment button:', error); + await interaction.reply({ content: 'An error occurred.', ephemeral: true }).catch(() => {}); + } +} + +export default { customId: 'punish_processed', execute }; diff --git a/src/interactions/buttons/punish_reviewed.js b/src/interactions/buttons/punish_reviewed.js new file mode 100644 index 000000000..1683c41be --- /dev/null +++ b/src/interactions/buttons/punish_reviewed.js @@ -0,0 +1,63 @@ +// src/buttons/punish_reviewed.js +// Handles punishment log status buttons +// Place this file in src/buttons/ — your bot will auto-register it via client.buttons + +import { EmbedBuilder } from 'discord.js'; +import { logger } from '../utils/logger.js'; + +const BUTTON_LABELS = { + punish_reviewed: '✅ Reviewed by IA/HC', + punish_processed: '🏢 Department Hub Processed', + punish_roster: '🔄 Roles & Roster Updated', + punish_rosterlink: '📋 Roster', +}; + +async function execute(interaction, client, args) { + try { + const customId = interaction.customId; + + // Find which button type this is + const buttonType = Object.keys(BUTTON_LABELS).find(key => customId.startsWith(key)); + if (!buttonType) return; + + const caseCode = customId.replace(`${buttonType}_`, ''); + const label = BUTTON_LABELS[buttonType]; + + const originalEmbed = interaction.message.embeds[0]; + if (!originalEmbed) return; + + const updatedEmbed = EmbedBuilder.from(originalEmbed); + + // Prevent double-marking + const alreadySet = (updatedEmbed.data.fields || []).some(f => f.name === label); + if (alreadySet) { + await interaction.reply({ content: 'This status has already been marked.', ephemeral: true }); + return; + } + + const timestamp = new Date().toLocaleDateString('en-US', { + month: 'short', day: 'numeric', year: 'numeric', + hour: '2-digit', minute: '2-digit', + }); + + updatedEmbed.addFields({ + name: label, + value: `By <@${interaction.user.id}> • ${timestamp}`, + inline: false, + }); + + await interaction.message.edit({ embeds: [updatedEmbed] }); + await interaction.reply({ + content: `✅ Marked **${label}** for case \`${caseCode}\`.`, + ephemeral: true, + }); + } catch (error) { + logger.error('Error handling punishment button:', error); + await interaction.reply({ + content: 'An error occurred while updating the punishment log.', + ephemeral: true, + }).catch(() => {}); + } +} + +export default { customId: 'punish_reviewed', execute }; diff --git a/src/interactions/buttons/punish_roster.js b/src/interactions/buttons/punish_roster.js new file mode 100644 index 000000000..0f309c4fd --- /dev/null +++ b/src/interactions/buttons/punish_roster.js @@ -0,0 +1,54 @@ +// src/buttons/punish_roster.js +import { EmbedBuilder } from 'discord.js'; +import { logger } from '../utils/logger.js'; + +const BUTTON_LABELS = { + punish_reviewed: '✅ Reviewed by IA/HC', + punish_processed: '🏢 Department Hub Processed', + punish_roster: '🔄 Roles & Roster Updated', + punish_rosterlink: '📋 Roster', +}; + +async function execute(interaction, client, args) { + try { + const customId = interaction.customId; + const buttonType = Object.keys(BUTTON_LABELS).find(key => customId.startsWith(key)); + if (!buttonType) return; + + const caseCode = customId.replace(`${buttonType}_`, ''); + const label = BUTTON_LABELS[buttonType]; + + const originalEmbed = interaction.message.embeds[0]; + if (!originalEmbed) return; + + const updatedEmbed = EmbedBuilder.from(originalEmbed); + + const alreadySet = (updatedEmbed.data.fields || []).some(f => f.name === label); + if (alreadySet) { + await interaction.reply({ content: 'This status has already been marked.', ephemeral: true }); + return; + } + + const timestamp = new Date().toLocaleDateString('en-US', { + month: 'short', day: 'numeric', year: 'numeric', + hour: '2-digit', minute: '2-digit', + }); + + updatedEmbed.addFields({ + name: label, + value: `By <@${interaction.user.id}> • ${timestamp}`, + inline: false, + }); + + await interaction.message.edit({ embeds: [updatedEmbed] }); + await interaction.reply({ + content: `✅ Marked **${label}** for case \`${caseCode}\`.`, + ephemeral: true, + }); + } catch (error) { + logger.error('Error handling punishment button:', error); + await interaction.reply({ content: 'An error occurred.', ephemeral: true }).catch(() => {}); + } +} + +export default { customId: 'punish_roster', execute }; diff --git a/src/interactions/buttons/punish_rosterlink.js b/src/interactions/buttons/punish_rosterlink.js new file mode 100644 index 000000000..25d07ef2b --- /dev/null +++ b/src/interactions/buttons/punish_rosterlink.js @@ -0,0 +1,54 @@ +// src/buttons/punish_rosterlink.js +import { EmbedBuilder } from 'discord.js'; +import { logger } from '../utils/logger.js'; + +const BUTTON_LABELS = { + punish_reviewed: '✅ Reviewed by IA/HC', + punish_processed: '🏢 Department Hub Processed', + punish_roster: '🔄 Roles & Roster Updated', + punish_rosterlink: '📋 Roster', +}; + +async function execute(interaction, client, args) { + try { + const customId = interaction.customId; + const buttonType = Object.keys(BUTTON_LABELS).find(key => customId.startsWith(key)); + if (!buttonType) return; + + const caseCode = customId.replace(`${buttonType}_`, ''); + const label = BUTTON_LABELS[buttonType]; + + const originalEmbed = interaction.message.embeds[0]; + if (!originalEmbed) return; + + const updatedEmbed = EmbedBuilder.from(originalEmbed); + + const alreadySet = (updatedEmbed.data.fields || []).some(f => f.name === label); + if (alreadySet) { + await interaction.reply({ content: 'This status has already been marked.', ephemeral: true }); + return; + } + + const timestamp = new Date().toLocaleDateString('en-US', { + month: 'short', day: 'numeric', year: 'numeric', + hour: '2-digit', minute: '2-digit', + }); + + updatedEmbed.addFields({ + name: label, + value: `By <@${interaction.user.id}> • ${timestamp}`, + inline: false, + }); + + await interaction.message.edit({ embeds: [updatedEmbed] }); + await interaction.reply({ + content: `✅ Marked **${label}** for case \`${caseCode}\`.`, + ephemeral: true, + }); + } catch (error) { + logger.error('Error handling punishment button:', error); + await interaction.reply({ content: 'An error occurred.', ephemeral: true }).catch(() => {}); + } +} + +export default { customId: 'punish_rosterlink', execute }; From 338a4e9fb9b823a68b16103c96b5a4a91b3a044d Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Fri, 19 Jun 2026 22:56:44 -0600 Subject: [PATCH 004/115] Update punish.js --- src/commands/Moderation/punish.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/commands/Moderation/punish.js b/src/commands/Moderation/punish.js index 0dc9d0643..b2142df0d 100644 --- a/src/commands/Moderation/punish.js +++ b/src/commands/Moderation/punish.js @@ -5,6 +5,7 @@ import { ButtonBuilder, ButtonStyle, EmbedBuilder, + MessageFlags, } from 'discord.js'; import { logModerationAction, generateCaseId, storeModerationCase } from '../../utils/moderation.js'; import { logger } from '../../utils/logger.js'; @@ -90,7 +91,7 @@ export default { async execute(interaction, config, client) { try { - await interaction.deferReply({ ephemeral: true }); + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); const member = interaction.options.getMember('member'); const user = interaction.options.getUser('member'); @@ -212,8 +213,11 @@ export default { ); // Send to punishment log channel - const logChannel = interaction.guild.channels.cache.get(PUNISHMENT_LOG_CHANNEL_ID); + let logChannel = interaction.guild.channels.cache.get(PUNISHMENT_LOG_CHANNEL_ID); if (!logChannel) { + logChannel = await interaction.guild.channels.fetch(PUNISHMENT_LOG_CHANNEL_ID).catch(() => null); + } + if (!logChannel || !logChannel.isTextBased()) { throw new TitanBotError('Log channel not found', ErrorTypes.CONFIGURATION, 'Punishment log channel not found. Please check the channel ID.', { subtype: 'missing_channel' }); } From 453def23e069ad496e7ca753f8414939f39f10b9 Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Fri, 19 Jun 2026 23:05:30 -0600 Subject: [PATCH 005/115] Update punish.js --- src/commands/Moderation/punish.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/commands/Moderation/punish.js b/src/commands/Moderation/punish.js index b2142df0d..25f0babed 100644 --- a/src/commands/Moderation/punish.js +++ b/src/commands/Moderation/punish.js @@ -214,9 +214,15 @@ export default { // Send to punishment log channel let logChannel = interaction.guild.channels.cache.get(PUNISHMENT_LOG_CHANNEL_ID); + logger.info(`Cache lookup: ${logChannel ? logChannel.name : 'not in cache'}`); if (!logChannel) { - logChannel = await interaction.guild.channels.fetch(PUNISHMENT_LOG_CHANNEL_ID).catch(() => null); + logChannel = await interaction.guild.channels.fetch(PUNISHMENT_LOG_CHANNEL_ID).catch((err) => { + logger.error(`Fetch failed: ${err.message}`); + return null; + }); + logger.info(`Fetch result: ${logChannel ? logChannel.name : 'null'}`); } + logger.info(`isTextBased: ${logChannel?.isTextBased()}`); if (!logChannel || !logChannel.isTextBased()) { throw new TitanBotError('Log channel not found', ErrorTypes.CONFIGURATION, 'Punishment log channel not found. Please check the channel ID.', { subtype: 'missing_channel' }); } From 868db3849bfc5ac0db2b422aa1ab0b46c9a0454e Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Fri, 19 Jun 2026 23:13:34 -0600 Subject: [PATCH 006/115] Update punish.js --- src/commands/Moderation/punish.js | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/src/commands/Moderation/punish.js b/src/commands/Moderation/punish.js index 25f0babed..bc4e672c2 100644 --- a/src/commands/Moderation/punish.js +++ b/src/commands/Moderation/punish.js @@ -212,29 +212,32 @@ export default { .setStyle(ButtonStyle.Secondary), ); - // Send to punishment log channel + // Send to punishment log forum channel let logChannel = interaction.guild.channels.cache.get(PUNISHMENT_LOG_CHANNEL_ID); - logger.info(`Cache lookup: ${logChannel ? logChannel.name : 'not in cache'}`); if (!logChannel) { logChannel = await interaction.guild.channels.fetch(PUNISHMENT_LOG_CHANNEL_ID).catch((err) => { logger.error(`Fetch failed: ${err.message}`); return null; }); - logger.info(`Fetch result: ${logChannel ? logChannel.name : 'null'}`); } - logger.info(`isTextBased: ${logChannel?.isTextBased()}`); - if (!logChannel || !logChannel.isTextBased()) { + if (!logChannel) { throw new TitanBotError('Log channel not found', ErrorTypes.CONFIGURATION, 'Punishment log channel not found. Please check the channel ID.', { subtype: 'missing_channel' }); } - const logMessage = await logChannel.send({ - embeds: [embed], - components: [buttons], - files: evidenceAttachments.length > 1 - ? evidenceAttachments.slice(1).map(att => att.url) - : [], + // Create a new forum post for this punishment case + const forumPost = await logChannel.threads.create({ + name: `Case ${caseCode} — ${user.username} — ${punishmentType}`, + message: { + embeds: [embed], + components: [buttons], + files: evidenceAttachments.length > 0 + ? evidenceAttachments.map(att => att.url) + : [], + }, }); + const logMessage = forumPost.messages.cache.first() || { id: forumPost.id }; + // Store the case in the database await storeModerationCase({ guildId: interaction.guild.id, From 87a4f96aaaeae4d5a552469e510e6b350f0a8571 Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Fri, 19 Jun 2026 23:18:08 -0600 Subject: [PATCH 007/115] Update punish.js --- src/commands/Moderation/punish.js | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/src/commands/Moderation/punish.js b/src/commands/Moderation/punish.js index bc4e672c2..91e36243c 100644 --- a/src/commands/Moderation/punish.js +++ b/src/commands/Moderation/punish.js @@ -196,20 +196,8 @@ export default { const buttons = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`punish_reviewed_${caseCode}`) - .setLabel('✅ Reviewed by IA/HC') + .setLabel('✅ Reviewed by Management') .setStyle(ButtonStyle.Success), - new ButtonBuilder() - .setCustomId(`punish_processed_${caseCode}`) - .setLabel('Department Hub Processed') - .setStyle(ButtonStyle.Primary), - new ButtonBuilder() - .setCustomId(`punish_roster_${caseCode}`) - .setLabel('Roles & Roster Updated') - .setStyle(ButtonStyle.Danger), - new ButtonBuilder() - .setCustomId(`punish_rosterlink_${caseCode}`) - .setLabel('Roster') - .setStyle(ButtonStyle.Secondary), ); // Send to punishment log forum channel From 1423b6af5d70d3c26a466de484f917fd9c4ce9ec Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Fri, 19 Jun 2026 23:23:33 -0600 Subject: [PATCH 008/115] Update punish_reviewed.js --- src/interactions/buttons/punish_reviewed.js | 32 ++++++++------------- 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/src/interactions/buttons/punish_reviewed.js b/src/interactions/buttons/punish_reviewed.js index 1683c41be..a49eea703 100644 --- a/src/interactions/buttons/punish_reviewed.js +++ b/src/interactions/buttons/punish_reviewed.js @@ -1,26 +1,17 @@ -// src/buttons/punish_reviewed.js -// Handles punishment log status buttons -// Place this file in src/buttons/ — your bot will auto-register it via client.buttons - import { EmbedBuilder } from 'discord.js'; -import { logger } from '../utils/logger.js'; +import { logger } from '../../utils/logger.js'; const BUTTON_LABELS = { punish_reviewed: '✅ Reviewed by IA/HC', - punish_processed: '🏢 Department Hub Processed', - punish_roster: '🔄 Roles & Roster Updated', - punish_rosterlink: '📋 Roster', }; -async function execute(interaction, client, args) { +async function execute(interaction, client) { try { const customId = interaction.customId; - - // Find which button type this is const buttonType = Object.keys(BUTTON_LABELS).find(key => customId.startsWith(key)); if (!buttonType) return; - const caseCode = customId.replace(`${buttonType}_`, ''); + const caseCode = customId.slice(buttonType.length + 1); const label = BUTTON_LABELS[buttonType]; const originalEmbed = interaction.message.embeds[0]; @@ -28,10 +19,9 @@ async function execute(interaction, client, args) { const updatedEmbed = EmbedBuilder.from(originalEmbed); - // Prevent double-marking const alreadySet = (updatedEmbed.data.fields || []).some(f => f.name === label); if (alreadySet) { - await interaction.reply({ content: 'This status has already been marked.', ephemeral: true }); + await interaction.reply({ content: 'This status has already been marked.', flags: 64 }); return; } @@ -49,15 +39,17 @@ async function execute(interaction, client, args) { await interaction.message.edit({ embeds: [updatedEmbed] }); await interaction.reply({ content: `✅ Marked **${label}** for case \`${caseCode}\`.`, - ephemeral: true, + flags: 64, }); } catch (error) { logger.error('Error handling punishment button:', error); - await interaction.reply({ - content: 'An error occurred while updating the punishment log.', - ephemeral: true, - }).catch(() => {}); + await interaction.reply({ content: 'An error occurred.', flags: 64 }).catch(() => {}); } } -export default { customId: 'punish_reviewed', execute }; +export default [ + { name: 'punish_reviewed', execute }, + { name: 'punish_processed', execute }, + { name: 'punish_roster', execute }, + { name: 'punish_rosterlink', execute }, +]; \ No newline at end of file From 8e57ce4c7f43aa97b2bae55f8411beb86bfa6a19 Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Fri, 19 Jun 2026 23:27:17 -0600 Subject: [PATCH 009/115] Update punish_reviewed.js --- src/interactions/buttons/punish_reviewed.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/interactions/buttons/punish_reviewed.js b/src/interactions/buttons/punish_reviewed.js index a49eea703..603fe496c 100644 --- a/src/interactions/buttons/punish_reviewed.js +++ b/src/interactions/buttons/punish_reviewed.js @@ -2,7 +2,7 @@ import { EmbedBuilder } from 'discord.js'; import { logger } from '../../utils/logger.js'; const BUTTON_LABELS = { - punish_reviewed: '✅ Reviewed by IA/HC', + punish_reviewed: '✅ Reviewed by Management', }; async function execute(interaction, client) { From 2c23408c3816c1fc5fe9ed85feab00acf4231104 Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Sat, 20 Jun 2026 14:01:23 -0600 Subject: [PATCH 010/115] Adding the Misic Depend --- package.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 2240607d7..7ac3fc678 100644 --- a/package.json +++ b/package.json @@ -16,10 +16,15 @@ }, "dependencies": { "@discordjs/rest": "^2.6.1", + "@discordjs/voice": "^0.17.0", + "@discord-player/extractor": "^4.5.0", "axios": "^1.15.2", "discord.js": "^14.26.4", + "discord-player": "^6.7.1", "dotenv": "^17.2.3", "express": "^5.1.0", + "ffmpeg-static": "^5.2.0", + "libsodium-wrappers": "^0.7.15", "node-cron": "^4.2.1", "pg": "^8.11.3", "winston": "^3.19.0", @@ -29,4 +34,4 @@ "engines": { "node": ">=18.0.0" } -} +} \ No newline at end of file From c6fde5ef9f936b004973a625fcff4ec11ed1aedf Mon Sep 17 00:00:00 2001 From: "railway-app[bot]" <68434857+railway-app[bot]@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:05:55 +0000 Subject: [PATCH 011/115] fix: replace node:20-alpine with node:20 in Dockerfile (#1) Co-authored-by: railway-app[bot] <68434857+railway-app[bot]@users.noreply.github.com> --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 576813ed8..9096edc7c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20-alpine +FROM node:20 # Create app directory WORKDIR /usr/src/app From 5393c470d7539bbb7079d27395d801b46a7cb01b Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Sat, 20 Jun 2026 14:16:15 -0600 Subject: [PATCH 012/115] Update Dockerfile --- Dockerfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 576813ed8..ea743d4f6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20-alpine +FROM node:20 # Create app directory WORKDIR /usr/src/app @@ -8,7 +8,7 @@ WORKDIR /usr/src/app COPY package*.json ./ # Install only production dependencies -RUN npm ci --omit=dev +RUN npm install --omit=dev # Bundle app source COPY . . @@ -17,4 +17,4 @@ COPY . . EXPOSE 3000 # Start the bot -CMD [ "npm", "start" ] +CMD [ "npm", "start" ] \ No newline at end of file From 39535c2cc3cf9de7429b7122de96ff9fa986ee9f Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Sat, 20 Jun 2026 14:22:45 -0600 Subject: [PATCH 013/115] Adding music --- src/commands/music/app.js | 461 ++++++++++++++++++++++++++++++++++++ src/commands/music/music.js | 224 ++++++++++++++++++ src/commands/music/play.js | 60 +++++ 3 files changed, 745 insertions(+) create mode 100644 src/commands/music/app.js create mode 100644 src/commands/music/music.js create mode 100644 src/commands/music/play.js diff --git a/src/commands/music/app.js b/src/commands/music/app.js new file mode 100644 index 000000000..90ee16ce6 --- /dev/null +++ b/src/commands/music/app.js @@ -0,0 +1,461 @@ +import 'dotenv/config'; +import { Client, Collection, GatewayIntentBits } from 'discord.js'; +import { REST } from '@discordjs/rest'; +import express from 'express'; +import cron from 'node-cron'; +import { Player } from 'discord-player'; +import { SpotifyExtractor } from '@discord-player/extractor'; + +import config from './config/application.js'; +import { initializeDatabase } from './utils/database.js'; +import { getGuildConfig } from './services/guildConfig.js'; +import { getServerCounters, saveServerCounters, updateCounter } from './services/serverstatsService.js'; +import { logger, startupLog, shutdownLog } from './utils/logger.js'; +import { checkBirthdays } from './services/birthdayService.js'; +import { checkGiveaways } from './services/giveawayService.js'; +import { loadCommands, registerCommands as registerSlashCommands } from './handlers/commandLoader.js'; +import pkg from '../package.json' with { type: 'json' }; +import { EXPECTED_SCHEMA_VERSION, EXPECTED_SCHEMA_LABEL } from './config/schemaVersion.js'; + +class TitanBot extends Client { + constructor() { + super({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMembers, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.GuildMessageReactions, + GatewayIntentBits.MessageContent, + GatewayIntentBits.DirectMessages, + GatewayIntentBits.GuildVoiceStates, + GatewayIntentBits.GuildBans, + ], + }); + + this.config = config; + this.commands = new Collection(); + this.events = new Collection(); + this.buttons = new Collection(); + this.selectMenus = new Collection(); + this.modals = new Collection(); + this.cooldowns = new Collection(); + this.db = null; + this.rest = new REST({ version: '10' }).setToken(config.bot.token); + this.player = null; + } + + async initializePlayer() { + try { + this.player = new Player(this, { + skipFFmpeg: false, + }); + + await this.player.extractors.register(SpotifyExtractor, { + clientId: process.env.SPOTIFY_CLIENT_ID, + clientSecret: process.env.SPOTIFY_CLIENT_SECRET, + }); + + await this.player.extractors.loadDefault((ext) => + !['YouTubeExtractor'].includes(ext) + ); + + this.player.events.on('playerStart', (queue, track) => { + queue.metadata?.channel?.send({ + content: `🎵 Now playing **${track.title}** by **${track.author}**`, + }).catch(() => {}); + }); + + this.player.events.on('emptyQueue', (queue) => { + queue.metadata?.channel?.send({ + content: '✅ Queue finished! Add more songs with `/play`.', + }).catch(() => {}); + }); + + this.player.events.on('emptyChannel', (queue) => { + queue.metadata?.channel?.send({ + content: '👋 Left the voice channel as it was empty.', + }).catch(() => {}); + }); + + this.player.events.on('error', (queue, error) => { + logger.error('Player error:', error); + queue.metadata?.channel?.send({ + content: `❌ An error occurred: ${error.message}`, + }).catch(() => {}); + }); + + startupLog('✅ Music player initialized'); + } catch (error) { + logger.error('Failed to initialize music player:', error); + } + } + + async start() { + try { + startupLog('Starting TitanBot...'); + await new Promise(resolve => setTimeout(resolve, 1000)); + + startupLog('Initializing database...'); + const dbInstance = await initializeDatabase(); + this.db = dbInstance.db; + + const dbStatus = this.db.getStatus(); + if (dbStatus.isDegraded) { + logger.warn(''); + logger.warn('╔═══════════════════════════════════════════════════════╗'); + logger.warn('║ ⚠️ DATABASE RUNNING IN DEGRADED MODE ║'); + logger.warn('║ ║'); + logger.warn('║ Connection: In-Memory Storage (PostgreSQL unavailable)║'); + logger.warn('║ Data Persistence: DISABLED - data lost on restart ║'); + logger.warn('║ Action Required: Fix PostgreSQL and restart bot ║'); + logger.warn('╚═══════════════════════════════════════════════════════╝'); + logger.warn(''); + } else { + startupLog(`✅ Database Status: ${dbStatus.connectionType} (fully operational)`); + } + + startupLog('Starting web server...'); + this.startWebServer(); + + startupLog('Initializing music player...'); + await this.initializePlayer(); + + startupLog('Loading commands...'); + await loadCommands(this); + startupLog(`Commands loaded: ${this.commands.size}`); + + startupLog('Loading handlers...'); + await this.loadHandlers(); + startupLog('Handlers loaded'); + + startupLog('Logging into Discord...'); + await this.login(this.config.bot.token); + startupLog('Discord login successful'); + + startupLog('Registering slash commands...'); + await this.registerCommands(); + if (this.config.bot.multiGuild) { + startupLog('Multi-guild mode enabled — slash commands registered globally'); + } else if (this.config.bot.guildId) { + startupLog(`Single-guild mode — slash commands registered for guild ${this.config.bot.guildId}`); + } + startupLog('Slash commands registration complete'); + + const databaseMode = dbStatus.isDegraded + ? 'Optional in-memory mode (data resets after restart)' + : 'Connected (persistent data enabled)'; + const handlerSummary = `${this.buttons.size} buttons, ${this.selectMenus.size} menus, ${this.modals.size} modals`; + startupLog( + `ONLINE ✅ | ${this.commands.size} commands loaded | ${handlerSummary} | Database: ${databaseMode}` + ); + + this.setupCronJobs(); + } catch (error) { + logger.error('Failed to start bot:', error); + process.exit(1); + } + } + + startWebServer() { + const app = express(); + const configuredPort = Number(this.config.api?.port || process.env.PORT || 3000); + const maxPortRetryAttempts = Number(process.env.PORT_RETRY_ATTEMPTS || 5); + const host = process.env.WEB_HOST || '0.0.0.0'; + const corsOrigin = this.config.api?.cors?.origin || '*'; + + app.use((req, res, next) => { + const allowedOrigins = Array.isArray(corsOrigin) ? corsOrigin : [corsOrigin]; + const origin = req.headers.origin; + + if (allowedOrigins.includes('*') || allowedOrigins.includes(origin)) { + res.header('Access-Control-Allow-Origin', origin || '*'); + } + res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + + if (req.method === 'OPTIONS') { + return res.sendStatus(200); + } + next(); + }); + + const requestCounts = new Map(); + const windowMs = 60000; + const maxRequests = this.config.api?.rateLimit?.max || 100; + + app.use((req, res, next) => { + const ip = req.ip; + const now = Date.now(); + const windowStart = now - windowMs; + + if (!requestCounts.has(ip)) { + requestCounts.set(ip, []); + } + + const times = requestCounts.get(ip).filter(t => t > windowStart); + + if (times.length >= maxRequests) { + return res.status(429).json({ error: 'Too many requests' }); + } + + times.push(now); + requestCounts.set(ip, times); + next(); + }); + + app.get('/health', (req, res) => { + const dbStatus = this.db?.getStatus?.() || { isDegraded: 'unknown' }; + const status = { + status: 'healthy', + timestamp: new Date().toISOString(), + uptime: process.uptime(), + database: { + connected: dbStatus.connectionType !== 'none', + degraded: dbStatus.isDegraded, + type: dbStatus.connectionType + } + }; + res.status(200).json(status); + }); + + app.get('/ready', (req, res) => { + const dbStatus = this.db?.getStatus?.() || { isDegraded: true, connectionType: 'none' }; + const isReady = this.isReady() && !dbStatus.isDegraded; + + const metrics = { + guildCount: this.guilds?.cache?.size ?? 0, + commandCount: this.commands?.size ?? 0, + database: { + mode: dbStatus.connectionType, + degraded: dbStatus.isDegraded, + degradedReason: dbStatus.degradedReason ?? null, + }, + schemaVersion: EXPECTED_SCHEMA_VERSION, + schemaLabel: EXPECTED_SCHEMA_LABEL, + }; + + if (isReady) { + return res.status(200).json({ + ready: true, + message: 'Bot is ready', + metrics, + }); + } + + res.status(503).json({ + ready: false, + reason: !this.isReady() ? 'Bot not Ready' : 'Database degraded', + metrics, + }); + }); + + app.get('/', (req, res) => { + res.status(200).json({ + message: 'TitanBot System Online', + version: pkg.version, + timestamp: new Date().toISOString() + }); + }); + + const startServer = (port, attempt = 0) => { + let hasStartedListening = false; + const server = app.listen(port, host, () => { + hasStartedListening = true; + this.webServer = server; + startupLog(`✅ Web Server running on ${host}:${port}`); + startupLog(`Health endpoint: http://${host}:${port}/health`); + startupLog(`Ready endpoint: http://${host}:${port}/ready`); + }); + + server.on('error', (error) => { + const errorCode = error?.code || 'UNKNOWN_ERROR'; + const errorMessage = error?.message || 'Unknown server error'; + + if (!hasStartedListening && errorCode === 'EADDRINUSE' && attempt < maxPortRetryAttempts) { + const nextPort = port + 1; + startupLog(`Port ${port} is already in use. Trying port ${nextPort}...`); + setTimeout(() => startServer(nextPort, attempt + 1), 250); + return; + } + + if (hasStartedListening && errorCode === 'EADDRINUSE') { + logger.warn(`Web server reported a duplicate bind warning on ${host}:${port}, but the bot remains online.`); + return; + } + + logger.error(`❌ Web server error on port ${port} (${errorCode}): ${errorMessage}`); + + if (!hasStartedListening) { + process.exit(1); + } + }); + }; + + startServer(configuredPort, 0); + } + + setupCronJobs() { + cron.schedule('0 6 * * *', () => checkBirthdays(this)); + cron.schedule('* * * * *', () => checkGiveaways(this)); + cron.schedule('*/15 * * * *', () => this.updateAllCounters()); + } + + async updateAllCounters() { + if (!this.db) { + logger.warn('Database not available for counter updates'); + return; + } + + for (const [guildId, guild] of this.guilds.cache) { + try { + const counters = await getServerCounters(this, guildId); + const validCounters = []; + const orphanedCounters = []; + + for (const counter of counters) { + if (counter && counter.type && counter.channelId && counter.enabled !== false) { + const channel = guild.channels.cache.get(counter.channelId); + if (channel) { + validCounters.push(counter); + await updateCounter(this, guild, counter); + } else { + orphanedCounters.push(counter); + logger.info(`Removing orphaned counter ${counter.id} (type: ${counter.type}, deleted channel: ${counter.channelId}) from guild ${guildId}`); + } + } + } + + if (orphanedCounters.length > 0) { + await saveServerCounters(this, guildId, validCounters); + logger.info(`Cleaned up ${orphanedCounters.length} orphaned counter(s) from guild ${guildId} during scheduled update`); + } + } catch (error) { + logger.error(`Error updating counters for guild ${guildId}:`, error); + } + } + } + + async loadHandlers() { + startupLog('Loading handlers...'); + const handlers = [ + { path: 'events', type: 'default', required: true }, + { path: 'interactions', type: 'default', required: true } + ]; + + for (const handler of handlers) { + try { + startupLog(`Loading handler: ${handler.path}`); + const module = await import(`./handlers/${handler.path}.js`); + const loaderFn = handler.type.startsWith('named:') + ? module[handler.type.split(':')[1]] + : module.default; + + if (typeof loaderFn === 'function') { + await loaderFn(this); + startupLog(`✅ Loaded ${handler.path}`); + } else { + throw new Error(`Invalid loader export from ${handler.path}`); + } + } catch (error) { + if (handler.required) { + logger.error(`❌ Failed to load required handler ${handler.path}:`, error.message); + throw error; + } else if (error.code !== 'MODULE_NOT_FOUND') { + logger.warn(`⚠️ Failed to load optional handler ${handler.path}:`, error.message); + } + } + } + } + + async registerCommands() { + try { + const { clientId, guildId, multiGuild } = this.config.bot; + await registerSlashCommands(this, { clientId, guildId, multiGuild }); + } catch (error) { + logger.error('Error registering commands:', error); + } + } + + async shutdown(reason = 'UNKNOWN') { + shutdownLog(`Bot is shutting down (${reason})...`); + logger.info(`\n${'='.repeat(60)}`); + logger.info(`🛑 Graceful Shutdown Initiated (${reason})`); + logger.info(`${'='.repeat(60)}`); + + try { + logger.info('Stopping cron jobs...'); + cron.getTasks().forEach(task => task.stop()); + logger.info('✅ Cron jobs stopped'); + + if (this.player) { + this.player.destroy(); + logger.info('✅ Music player destroyed'); + } + + if (this.db && this.db.db) { + logger.info('Closing database connection...'); + try { + if (this.db.db.pool) { + await this.db.db.pool.end(); + logger.info('✅ Database connection closed'); + } + } catch (error) { + logger.warn('Error closing database pool:', error.message); + } + } + + logger.info('Destroying Discord client...'); + if (this.isReady()) { + try { + this.destroy(); + logger.info('✅ Discord client destroyed'); + } catch (error) { + logger.warn('Discord client destroy warning (non-critical):', error.message); + } + } + + logger.info('✅ Graceful shutdown complete'); + shutdownLog('Bot stopped successfully.'); + process.exit(0); + } catch (error) { + logger.error('Error during graceful shutdown:', error); + process.exit(1); + } + } +} + +try { + const bot = new TitanBot(); + + const setupShutdown = () => { + process.on('SIGTERM', () => bot.shutdown('SIGTERM')); + process.on('SIGINT', () => bot.shutdown('SIGINT')); + + process.on('uncaughtException', (error) => { + logger.error('Uncaught Exception:', error); + bot.shutdown('UNCAUGHT_EXCEPTION'); + }); + + process.on('unhandledRejection', (reason, promise) => { + const code = reason?.code; + if (code === 10062 || code === 40060 || code === 50027) { + logger.warn('Recoverable Discord interaction rejection:', reason?.message || reason); + return; + } + + logger.error('Unhandled Rejection at:', promise, 'reason:', reason); + bot.shutdown('UNHANDLED_REJECTION'); + }); + }; + + setupShutdown(); + bot.start().catch((error) => { + logger.error('Fatal error during bot startup:', error); + bot.shutdown('STARTUP_ERROR'); + }); +} catch (error) { + logger.error('Fatal error during bot startup:', error); + process.exit(1); +} + +export default TitanBot; diff --git a/src/commands/music/music.js b/src/commands/music/music.js new file mode 100644 index 000000000..882108bf3 --- /dev/null +++ b/src/commands/music/music.js @@ -0,0 +1,224 @@ +import { SlashCommandBuilder, EmbedBuilder } from 'discord.js'; +import { useQueue } from 'discord-player'; + +// Helper to check queue exists +function getQueue(interaction) { + const queue = useQueue(interaction.guild.id); + return queue; +} + +export const skip = { + data: new SlashCommandBuilder() + .setName('skip') + .setDescription('Skip the current song'), + category: 'music', + async execute(interaction) { + const queue = getQueue(interaction); + if (!queue || !queue.isPlaying()) { + return interaction.reply({ content: '❌ No music is playing!', ephemeral: true }); + } + const track = queue.currentTrack; + queue.node.skip(); + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(0x1DB954) + .setDescription(`⏭️ Skipped **${track?.title || 'current track'}**`) + ] + }); + }, +}; + +export const stop = { + data: new SlashCommandBuilder() + .setName('stop') + .setDescription('Stop music and clear the queue'), + category: 'music', + async execute(interaction) { + const queue = getQueue(interaction); + if (!queue || !queue.isPlaying()) { + return interaction.reply({ content: '❌ No music is playing!', ephemeral: true }); + } + queue.delete(); + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(0xE74C3C) + .setDescription('⏹️ Stopped music and cleared the queue.') + ] + }); + }, +}; + +export const pause = { + data: new SlashCommandBuilder() + .setName('pause') + .setDescription('Pause the current song'), + category: 'music', + async execute(interaction) { + const queue = getQueue(interaction); + if (!queue || !queue.isPlaying()) { + return interaction.reply({ content: '❌ No music is playing!', ephemeral: true }); + } + if (queue.node.isPaused()) { + return interaction.reply({ content: '⏸️ Music is already paused!', ephemeral: true }); + } + queue.node.pause(); + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(0xF39C12) + .setDescription('⏸️ Paused the music.') + ] + }); + }, +}; + +export const resume = { + data: new SlashCommandBuilder() + .setName('resume') + .setDescription('Resume the paused song'), + category: 'music', + async execute(interaction) { + const queue = getQueue(interaction); + if (!queue) { + return interaction.reply({ content: '❌ No music in queue!', ephemeral: true }); + } + if (!queue.node.isPaused()) { + return interaction.reply({ content: '▶️ Music is already playing!', ephemeral: true }); + } + queue.node.resume(); + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(0x1DB954) + .setDescription('▶️ Resumed the music.') + ] + }); + }, +}; + +export const queue = { + data: new SlashCommandBuilder() + .setName('queue') + .setDescription('Show the current music queue'), + category: 'music', + async execute(interaction) { + const q = getQueue(interaction); + if (!q || !q.isPlaying()) { + return interaction.reply({ content: '❌ No music is playing!', ephemeral: true }); + } + + const current = q.currentTrack; + const tracks = q.tracks.toArray().slice(0, 10); + + const embed = new EmbedBuilder() + .setColor(0x1DB954) + .setTitle('🎵 Music Queue') + .addFields({ + name: '▶️ Now Playing', + value: `**${current?.title}** by ${current?.author} (${current?.duration})`, + inline: false, + }); + + if (tracks.length > 0) { + embed.addFields({ + name: '📋 Up Next', + value: tracks.map((t, i) => `\`${i + 1}.\` **${t.title}** by ${t.author} (${t.duration})`).join('\n'), + inline: false, + }); + } else { + embed.addFields({ name: '📋 Up Next', value: 'Nothing in queue', inline: false }); + } + + embed.setFooter({ text: `${q.tracks.size} song(s) in queue` }); + + await interaction.reply({ embeds: [embed] }); + }, +}; + +export const nowplaying = { + data: new SlashCommandBuilder() + .setName('nowplaying') + .setDescription('Show the currently playing song'), + category: 'music', + async execute(interaction) { + const queue = getQueue(interaction); + if (!queue || !queue.isPlaying()) { + return interaction.reply({ content: '❌ No music is playing!', ephemeral: true }); + } + + const track = queue.currentTrack; + const progress = queue.node.createProgressBar(); + + const embed = new EmbedBuilder() + .setColor(0x1DB954) + .setTitle('🎵 Now Playing') + .setDescription(`**[${track.title}](${track.url})**`) + .addFields( + { name: 'Artist', value: track.author || 'Unknown', inline: true }, + { name: 'Duration', value: track.duration || 'Unknown', inline: true }, + { name: 'Requested by', value: `<@${track.requestedBy?.id || interaction.user.id}>`, inline: true }, + { name: 'Progress', value: progress || 'Unknown', inline: false }, + ) + .setThumbnail(track.thumbnail) + .setTimestamp(); + + await interaction.reply({ embeds: [embed] }); + }, +}; + +export const volume = { + data: new SlashCommandBuilder() + .setName('volume') + .setDescription('Set the music volume') + .addIntegerOption(option => + option.setName('level') + .setDescription('Volume level (1-100)') + .setMinValue(1) + .setMaxValue(100) + .setRequired(true) + ), + category: 'music', + async execute(interaction) { + const queue = getQueue(interaction); + if (!queue || !queue.isPlaying()) { + return interaction.reply({ content: '❌ No music is playing!', ephemeral: true }); + } + const level = interaction.options.getInteger('level'); + queue.node.setVolume(level); + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(0x1DB954) + .setDescription(`🔊 Volume set to **${level}%**`) + ] + }); + }, +}; + +export const shuffle = { + data: new SlashCommandBuilder() + .setName('shuffle') + .setDescription('Shuffle the music queue'), + category: 'music', + async execute(interaction) { + const queue = getQueue(interaction); + if (!queue || queue.tracks.size < 2) { + return interaction.reply({ content: '❌ Not enough songs in the queue to shuffle!', ephemeral: true }); + } + queue.tracks.shuffle(); + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(0x1DB954) + .setDescription('🔀 Queue shuffled!') + ] + }); + }, +}; + +// Default export for the command loader (exports all as array) +export default [ + skip, stop, pause, resume, queue, nowplaying, volume, shuffle +]; diff --git a/src/commands/music/play.js b/src/commands/music/play.js new file mode 100644 index 000000000..4b6a96e10 --- /dev/null +++ b/src/commands/music/play.js @@ -0,0 +1,60 @@ +import { SlashCommandBuilder, EmbedBuilder } from 'discord.js'; +import { useMainPlayer } from 'discord-player'; +import { logger } from '../../utils/logger.js'; + +export default { + data: new SlashCommandBuilder() + .setName('play') + .setDescription('Play a song from Spotify or search by name') + .addStringOption(option => + option.setName('query') + .setDescription('Song name or Spotify URL') + .setRequired(true) + ), + category: 'music', + + async execute(interaction, config, client) { + await interaction.deferReply(); + + const voiceChannel = interaction.member?.voice?.channel; + if (!voiceChannel) { + return interaction.editReply({ content: '❌ You must be in a voice channel to play music!' }); + } + + const query = interaction.options.getString('query'); + const player = useMainPlayer(); + + try { + const { track } = await player.play(voiceChannel, query, { + nodeOptions: { + metadata: { + channel: interaction.channel, + }, + selfDeaf: true, + volume: 80, + leaveOnEmpty: true, + leaveOnEmptyCooldown: 30000, + leaveOnEnd: true, + leaveOnEndCooldown: 30000, + }, + }); + + const embed = new EmbedBuilder() + .setColor(0x1DB954) + .setTitle('🎵 Added to Queue') + .setDescription(`**[${track.title}](${track.url})**`) + .addFields( + { name: 'Artist', value: track.author || 'Unknown', inline: true }, + { name: 'Duration', value: track.duration || 'Unknown', inline: true }, + { name: 'Requested by', value: `<@${interaction.user.id}>`, inline: true }, + ) + .setThumbnail(track.thumbnail) + .setTimestamp(); + + await interaction.editReply({ embeds: [embed] }); + } catch (error) { + logger.error('Play command error:', error); + await interaction.editReply({ content: `❌ Could not play that track: ${error.message}` }); + } + }, +}; From da2f17f527fa609ae7aeabb535dbba23394f00bc Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Sat, 20 Jun 2026 14:24:58 -0600 Subject: [PATCH 014/115] . --- src/app.js | 86 +++++-- src/commands/music/app.js | 461 -------------------------------------- 2 files changed, 66 insertions(+), 481 deletions(-) delete mode 100644 src/commands/music/app.js diff --git a/src/app.js b/src/app.js index bea0ab7d8..8c917d733 100644 --- a/src/app.js +++ b/src/app.js @@ -3,6 +3,8 @@ import { Client, Collection, GatewayIntentBits } from 'discord.js'; import { REST } from '@discordjs/rest'; import express from 'express'; import cron from 'node-cron'; +import { Player } from 'discord-player'; +import { SpotifyExtractor } from '@discord-player/extractor'; import config from './config/application.js'; import { initializeDatabase } from './utils/database.js'; @@ -19,18 +21,14 @@ class TitanBot extends Client { constructor() { super({ intents: [ - - GatewayIntentBits.Guilds, - GatewayIntentBits.GuildMembers, - - GatewayIntentBits.GuildMessages, - GatewayIntentBits.GuildMessageReactions, - GatewayIntentBits.MessageContent, + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMembers, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.GuildMessageReactions, + GatewayIntentBits.MessageContent, GatewayIntentBits.DirectMessages, - - GatewayIntentBits.GuildVoiceStates, - - GatewayIntentBits.GuildBans, + GatewayIntentBits.GuildVoiceStates, + GatewayIntentBits.GuildBans, ], }); @@ -43,6 +41,53 @@ class TitanBot extends Client { this.cooldowns = new Collection(); this.db = null; this.rest = new REST({ version: '10' }).setToken(config.bot.token); + this.player = null; + } + + async initializePlayer() { + try { + this.player = new Player(this, { + skipFFmpeg: false, + }); + + await this.player.extractors.register(SpotifyExtractor, { + clientId: process.env.SPOTIFY_CLIENT_ID, + clientSecret: process.env.SPOTIFY_CLIENT_SECRET, + }); + + await this.player.extractors.loadDefault((ext) => + !['YouTubeExtractor'].includes(ext) + ); + + this.player.events.on('playerStart', (queue, track) => { + queue.metadata?.channel?.send({ + content: `🎵 Now playing **${track.title}** by **${track.author}**`, + }).catch(() => {}); + }); + + this.player.events.on('emptyQueue', (queue) => { + queue.metadata?.channel?.send({ + content: '✅ Queue finished! Add more songs with `/play`.', + }).catch(() => {}); + }); + + this.player.events.on('emptyChannel', (queue) => { + queue.metadata?.channel?.send({ + content: '👋 Left the voice channel as it was empty.', + }).catch(() => {}); + }); + + this.player.events.on('error', (queue, error) => { + logger.error('Player error:', error); + queue.metadata?.channel?.send({ + content: `❌ An error occurred: ${error.message}`, + }).catch(() => {}); + }); + + startupLog('✅ Music player initialized'); + } catch (error) { + logger.error('Failed to initialize music player:', error); + } } async start() { @@ -54,7 +99,6 @@ class TitanBot extends Client { const dbInstance = await initializeDatabase(); this.db = dbInstance.db; - // Check database status and report const dbStatus = this.db.getStatus(); if (dbStatus.isDegraded) { logger.warn(''); @@ -72,6 +116,9 @@ class TitanBot extends Client { startupLog('Starting web server...'); this.startWebServer(); + + startupLog('Initializing music player...'); + await this.initializePlayer(); startupLog('Loading commands...'); await loadCommands(this); @@ -278,8 +325,6 @@ class TitanBot extends Client { } } - // Save cleaned counters if any were orphaned - // Save cleaned counters if any were orphaned if (orphanedCounters.length > 0) { await saveServerCounters(this, guildId, validCounters); logger.info(`Cleaned up ${orphanedCounters.length} orphaned counter(s) from guild ${guildId} during scheduled update`); @@ -338,13 +383,15 @@ class TitanBot extends Client { logger.info(`${'='.repeat(60)}`); try { - logger.info('Stopping cron jobs...'); cron.getTasks().forEach(task => task.stop()); logger.info('✅ Cron jobs stopped'); - // Close database connection - // Close database connection + if (this.player) { + this.player.destroy(); + logger.info('✅ Music player destroyed'); + } + if (this.db && this.db.db) { logger.info('Closing database connection...'); try { @@ -363,13 +410,12 @@ class TitanBot extends Client { this.destroy(); logger.info('✅ Discord client destroyed'); } catch (error) { - logger.warn('Discord client destroy warning (non-critical):', error.message); } } logger.info('✅ Graceful shutdown complete'); - shutdownLog('Bot stopped successfully.'); + shutdownLog('Bot stopped successfully.'); process.exit(0); } catch (error) { logger.error('Error during graceful shutdown:', error); @@ -412,4 +458,4 @@ try { process.exit(1); } -export default TitanBot; \ No newline at end of file +export default TitanBot; diff --git a/src/commands/music/app.js b/src/commands/music/app.js deleted file mode 100644 index 90ee16ce6..000000000 --- a/src/commands/music/app.js +++ /dev/null @@ -1,461 +0,0 @@ -import 'dotenv/config'; -import { Client, Collection, GatewayIntentBits } from 'discord.js'; -import { REST } from '@discordjs/rest'; -import express from 'express'; -import cron from 'node-cron'; -import { Player } from 'discord-player'; -import { SpotifyExtractor } from '@discord-player/extractor'; - -import config from './config/application.js'; -import { initializeDatabase } from './utils/database.js'; -import { getGuildConfig } from './services/guildConfig.js'; -import { getServerCounters, saveServerCounters, updateCounter } from './services/serverstatsService.js'; -import { logger, startupLog, shutdownLog } from './utils/logger.js'; -import { checkBirthdays } from './services/birthdayService.js'; -import { checkGiveaways } from './services/giveawayService.js'; -import { loadCommands, registerCommands as registerSlashCommands } from './handlers/commandLoader.js'; -import pkg from '../package.json' with { type: 'json' }; -import { EXPECTED_SCHEMA_VERSION, EXPECTED_SCHEMA_LABEL } from './config/schemaVersion.js'; - -class TitanBot extends Client { - constructor() { - super({ - intents: [ - GatewayIntentBits.Guilds, - GatewayIntentBits.GuildMembers, - GatewayIntentBits.GuildMessages, - GatewayIntentBits.GuildMessageReactions, - GatewayIntentBits.MessageContent, - GatewayIntentBits.DirectMessages, - GatewayIntentBits.GuildVoiceStates, - GatewayIntentBits.GuildBans, - ], - }); - - this.config = config; - this.commands = new Collection(); - this.events = new Collection(); - this.buttons = new Collection(); - this.selectMenus = new Collection(); - this.modals = new Collection(); - this.cooldowns = new Collection(); - this.db = null; - this.rest = new REST({ version: '10' }).setToken(config.bot.token); - this.player = null; - } - - async initializePlayer() { - try { - this.player = new Player(this, { - skipFFmpeg: false, - }); - - await this.player.extractors.register(SpotifyExtractor, { - clientId: process.env.SPOTIFY_CLIENT_ID, - clientSecret: process.env.SPOTIFY_CLIENT_SECRET, - }); - - await this.player.extractors.loadDefault((ext) => - !['YouTubeExtractor'].includes(ext) - ); - - this.player.events.on('playerStart', (queue, track) => { - queue.metadata?.channel?.send({ - content: `🎵 Now playing **${track.title}** by **${track.author}**`, - }).catch(() => {}); - }); - - this.player.events.on('emptyQueue', (queue) => { - queue.metadata?.channel?.send({ - content: '✅ Queue finished! Add more songs with `/play`.', - }).catch(() => {}); - }); - - this.player.events.on('emptyChannel', (queue) => { - queue.metadata?.channel?.send({ - content: '👋 Left the voice channel as it was empty.', - }).catch(() => {}); - }); - - this.player.events.on('error', (queue, error) => { - logger.error('Player error:', error); - queue.metadata?.channel?.send({ - content: `❌ An error occurred: ${error.message}`, - }).catch(() => {}); - }); - - startupLog('✅ Music player initialized'); - } catch (error) { - logger.error('Failed to initialize music player:', error); - } - } - - async start() { - try { - startupLog('Starting TitanBot...'); - await new Promise(resolve => setTimeout(resolve, 1000)); - - startupLog('Initializing database...'); - const dbInstance = await initializeDatabase(); - this.db = dbInstance.db; - - const dbStatus = this.db.getStatus(); - if (dbStatus.isDegraded) { - logger.warn(''); - logger.warn('╔═══════════════════════════════════════════════════════╗'); - logger.warn('║ ⚠️ DATABASE RUNNING IN DEGRADED MODE ║'); - logger.warn('║ ║'); - logger.warn('║ Connection: In-Memory Storage (PostgreSQL unavailable)║'); - logger.warn('║ Data Persistence: DISABLED - data lost on restart ║'); - logger.warn('║ Action Required: Fix PostgreSQL and restart bot ║'); - logger.warn('╚═══════════════════════════════════════════════════════╝'); - logger.warn(''); - } else { - startupLog(`✅ Database Status: ${dbStatus.connectionType} (fully operational)`); - } - - startupLog('Starting web server...'); - this.startWebServer(); - - startupLog('Initializing music player...'); - await this.initializePlayer(); - - startupLog('Loading commands...'); - await loadCommands(this); - startupLog(`Commands loaded: ${this.commands.size}`); - - startupLog('Loading handlers...'); - await this.loadHandlers(); - startupLog('Handlers loaded'); - - startupLog('Logging into Discord...'); - await this.login(this.config.bot.token); - startupLog('Discord login successful'); - - startupLog('Registering slash commands...'); - await this.registerCommands(); - if (this.config.bot.multiGuild) { - startupLog('Multi-guild mode enabled — slash commands registered globally'); - } else if (this.config.bot.guildId) { - startupLog(`Single-guild mode — slash commands registered for guild ${this.config.bot.guildId}`); - } - startupLog('Slash commands registration complete'); - - const databaseMode = dbStatus.isDegraded - ? 'Optional in-memory mode (data resets after restart)' - : 'Connected (persistent data enabled)'; - const handlerSummary = `${this.buttons.size} buttons, ${this.selectMenus.size} menus, ${this.modals.size} modals`; - startupLog( - `ONLINE ✅ | ${this.commands.size} commands loaded | ${handlerSummary} | Database: ${databaseMode}` - ); - - this.setupCronJobs(); - } catch (error) { - logger.error('Failed to start bot:', error); - process.exit(1); - } - } - - startWebServer() { - const app = express(); - const configuredPort = Number(this.config.api?.port || process.env.PORT || 3000); - const maxPortRetryAttempts = Number(process.env.PORT_RETRY_ATTEMPTS || 5); - const host = process.env.WEB_HOST || '0.0.0.0'; - const corsOrigin = this.config.api?.cors?.origin || '*'; - - app.use((req, res, next) => { - const allowedOrigins = Array.isArray(corsOrigin) ? corsOrigin : [corsOrigin]; - const origin = req.headers.origin; - - if (allowedOrigins.includes('*') || allowedOrigins.includes(origin)) { - res.header('Access-Control-Allow-Origin', origin || '*'); - } - res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); - res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization'); - - if (req.method === 'OPTIONS') { - return res.sendStatus(200); - } - next(); - }); - - const requestCounts = new Map(); - const windowMs = 60000; - const maxRequests = this.config.api?.rateLimit?.max || 100; - - app.use((req, res, next) => { - const ip = req.ip; - const now = Date.now(); - const windowStart = now - windowMs; - - if (!requestCounts.has(ip)) { - requestCounts.set(ip, []); - } - - const times = requestCounts.get(ip).filter(t => t > windowStart); - - if (times.length >= maxRequests) { - return res.status(429).json({ error: 'Too many requests' }); - } - - times.push(now); - requestCounts.set(ip, times); - next(); - }); - - app.get('/health', (req, res) => { - const dbStatus = this.db?.getStatus?.() || { isDegraded: 'unknown' }; - const status = { - status: 'healthy', - timestamp: new Date().toISOString(), - uptime: process.uptime(), - database: { - connected: dbStatus.connectionType !== 'none', - degraded: dbStatus.isDegraded, - type: dbStatus.connectionType - } - }; - res.status(200).json(status); - }); - - app.get('/ready', (req, res) => { - const dbStatus = this.db?.getStatus?.() || { isDegraded: true, connectionType: 'none' }; - const isReady = this.isReady() && !dbStatus.isDegraded; - - const metrics = { - guildCount: this.guilds?.cache?.size ?? 0, - commandCount: this.commands?.size ?? 0, - database: { - mode: dbStatus.connectionType, - degraded: dbStatus.isDegraded, - degradedReason: dbStatus.degradedReason ?? null, - }, - schemaVersion: EXPECTED_SCHEMA_VERSION, - schemaLabel: EXPECTED_SCHEMA_LABEL, - }; - - if (isReady) { - return res.status(200).json({ - ready: true, - message: 'Bot is ready', - metrics, - }); - } - - res.status(503).json({ - ready: false, - reason: !this.isReady() ? 'Bot not Ready' : 'Database degraded', - metrics, - }); - }); - - app.get('/', (req, res) => { - res.status(200).json({ - message: 'TitanBot System Online', - version: pkg.version, - timestamp: new Date().toISOString() - }); - }); - - const startServer = (port, attempt = 0) => { - let hasStartedListening = false; - const server = app.listen(port, host, () => { - hasStartedListening = true; - this.webServer = server; - startupLog(`✅ Web Server running on ${host}:${port}`); - startupLog(`Health endpoint: http://${host}:${port}/health`); - startupLog(`Ready endpoint: http://${host}:${port}/ready`); - }); - - server.on('error', (error) => { - const errorCode = error?.code || 'UNKNOWN_ERROR'; - const errorMessage = error?.message || 'Unknown server error'; - - if (!hasStartedListening && errorCode === 'EADDRINUSE' && attempt < maxPortRetryAttempts) { - const nextPort = port + 1; - startupLog(`Port ${port} is already in use. Trying port ${nextPort}...`); - setTimeout(() => startServer(nextPort, attempt + 1), 250); - return; - } - - if (hasStartedListening && errorCode === 'EADDRINUSE') { - logger.warn(`Web server reported a duplicate bind warning on ${host}:${port}, but the bot remains online.`); - return; - } - - logger.error(`❌ Web server error on port ${port} (${errorCode}): ${errorMessage}`); - - if (!hasStartedListening) { - process.exit(1); - } - }); - }; - - startServer(configuredPort, 0); - } - - setupCronJobs() { - cron.schedule('0 6 * * *', () => checkBirthdays(this)); - cron.schedule('* * * * *', () => checkGiveaways(this)); - cron.schedule('*/15 * * * *', () => this.updateAllCounters()); - } - - async updateAllCounters() { - if (!this.db) { - logger.warn('Database not available for counter updates'); - return; - } - - for (const [guildId, guild] of this.guilds.cache) { - try { - const counters = await getServerCounters(this, guildId); - const validCounters = []; - const orphanedCounters = []; - - for (const counter of counters) { - if (counter && counter.type && counter.channelId && counter.enabled !== false) { - const channel = guild.channels.cache.get(counter.channelId); - if (channel) { - validCounters.push(counter); - await updateCounter(this, guild, counter); - } else { - orphanedCounters.push(counter); - logger.info(`Removing orphaned counter ${counter.id} (type: ${counter.type}, deleted channel: ${counter.channelId}) from guild ${guildId}`); - } - } - } - - if (orphanedCounters.length > 0) { - await saveServerCounters(this, guildId, validCounters); - logger.info(`Cleaned up ${orphanedCounters.length} orphaned counter(s) from guild ${guildId} during scheduled update`); - } - } catch (error) { - logger.error(`Error updating counters for guild ${guildId}:`, error); - } - } - } - - async loadHandlers() { - startupLog('Loading handlers...'); - const handlers = [ - { path: 'events', type: 'default', required: true }, - { path: 'interactions', type: 'default', required: true } - ]; - - for (const handler of handlers) { - try { - startupLog(`Loading handler: ${handler.path}`); - const module = await import(`./handlers/${handler.path}.js`); - const loaderFn = handler.type.startsWith('named:') - ? module[handler.type.split(':')[1]] - : module.default; - - if (typeof loaderFn === 'function') { - await loaderFn(this); - startupLog(`✅ Loaded ${handler.path}`); - } else { - throw new Error(`Invalid loader export from ${handler.path}`); - } - } catch (error) { - if (handler.required) { - logger.error(`❌ Failed to load required handler ${handler.path}:`, error.message); - throw error; - } else if (error.code !== 'MODULE_NOT_FOUND') { - logger.warn(`⚠️ Failed to load optional handler ${handler.path}:`, error.message); - } - } - } - } - - async registerCommands() { - try { - const { clientId, guildId, multiGuild } = this.config.bot; - await registerSlashCommands(this, { clientId, guildId, multiGuild }); - } catch (error) { - logger.error('Error registering commands:', error); - } - } - - async shutdown(reason = 'UNKNOWN') { - shutdownLog(`Bot is shutting down (${reason})...`); - logger.info(`\n${'='.repeat(60)}`); - logger.info(`🛑 Graceful Shutdown Initiated (${reason})`); - logger.info(`${'='.repeat(60)}`); - - try { - logger.info('Stopping cron jobs...'); - cron.getTasks().forEach(task => task.stop()); - logger.info('✅ Cron jobs stopped'); - - if (this.player) { - this.player.destroy(); - logger.info('✅ Music player destroyed'); - } - - if (this.db && this.db.db) { - logger.info('Closing database connection...'); - try { - if (this.db.db.pool) { - await this.db.db.pool.end(); - logger.info('✅ Database connection closed'); - } - } catch (error) { - logger.warn('Error closing database pool:', error.message); - } - } - - logger.info('Destroying Discord client...'); - if (this.isReady()) { - try { - this.destroy(); - logger.info('✅ Discord client destroyed'); - } catch (error) { - logger.warn('Discord client destroy warning (non-critical):', error.message); - } - } - - logger.info('✅ Graceful shutdown complete'); - shutdownLog('Bot stopped successfully.'); - process.exit(0); - } catch (error) { - logger.error('Error during graceful shutdown:', error); - process.exit(1); - } - } -} - -try { - const bot = new TitanBot(); - - const setupShutdown = () => { - process.on('SIGTERM', () => bot.shutdown('SIGTERM')); - process.on('SIGINT', () => bot.shutdown('SIGINT')); - - process.on('uncaughtException', (error) => { - logger.error('Uncaught Exception:', error); - bot.shutdown('UNCAUGHT_EXCEPTION'); - }); - - process.on('unhandledRejection', (reason, promise) => { - const code = reason?.code; - if (code === 10062 || code === 40060 || code === 50027) { - logger.warn('Recoverable Discord interaction rejection:', reason?.message || reason); - return; - } - - logger.error('Unhandled Rejection at:', promise, 'reason:', reason); - bot.shutdown('UNHANDLED_REJECTION'); - }); - }; - - setupShutdown(); - bot.start().catch((error) => { - logger.error('Fatal error during bot startup:', error); - bot.shutdown('STARTUP_ERROR'); - }); -} catch (error) { - logger.error('Fatal error during bot startup:', error); - process.exit(1); -} - -export default TitanBot; From a2c5cbb96d617d1f28e940e2b52b735b5b61274a Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Sat, 20 Jun 2026 14:29:09 -0600 Subject: [PATCH 015/115] Added Youtube as a backup --- src/app.js | 7 +++---- src/commands/music/play.js | 9 +++++++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/app.js b/src/app.js index 8c917d733..7f9425d34 100644 --- a/src/app.js +++ b/src/app.js @@ -1,4 +1,4 @@ -import 'dotenv/config'; +import 'dotenv/config'; import { Client, Collection, GatewayIntentBits } from 'discord.js'; import { REST } from '@discordjs/rest'; import express from 'express'; @@ -55,9 +55,8 @@ class TitanBot extends Client { clientSecret: process.env.SPOTIFY_CLIENT_SECRET, }); - await this.player.extractors.loadDefault((ext) => - !['YouTubeExtractor'].includes(ext) - ); + // Load all default extractors including YouTube for search fallback + await this.player.extractors.loadDefault(); this.player.events.on('playerStart', (queue, track) => { queue.metadata?.channel?.send({ diff --git a/src/commands/music/play.js b/src/commands/music/play.js index 4b6a96e10..5fd1b9563 100644 --- a/src/commands/music/play.js +++ b/src/commands/music/play.js @@ -1,5 +1,5 @@ import { SlashCommandBuilder, EmbedBuilder } from 'discord.js'; -import { useMainPlayer } from 'discord-player'; +import { useMainPlayer, QueryType } from 'discord-player'; import { logger } from '../../utils/logger.js'; export default { @@ -25,7 +25,12 @@ export default { const player = useMainPlayer(); try { + // Detect if it's a Spotify URL or a plain search + const isSpotifyUrl = query.includes('spotify.com'); + const searchEngine = isSpotifyUrl ? QueryType.SPOTIFY_SONG : QueryType.AUTO; + const { track } = await player.play(voiceChannel, query, { + searchEngine, nodeOptions: { metadata: { channel: interaction.channel, @@ -54,7 +59,7 @@ export default { await interaction.editReply({ embeds: [embed] }); } catch (error) { logger.error('Play command error:', error); - await interaction.editReply({ content: `❌ Could not play that track: ${error.message}` }); + await interaction.editReply({ content: `❌ Could not play that track. Try using a direct Spotify link or a different search term.` }); } }, }; From 1433d8babe20f9eb08123d3da3b25f9f6419e33b Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Sat, 20 Jun 2026 14:34:51 -0600 Subject: [PATCH 016/115] Updated app.js and play.js --- src/app.js | 6 ++++-- src/commands/music/play.js | 17 +++++++++++------ 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/app.js b/src/app.js index 7f9425d34..e1ebcb57c 100644 --- a/src/app.js +++ b/src/app.js @@ -55,8 +55,10 @@ class TitanBot extends Client { clientSecret: process.env.SPOTIFY_CLIENT_SECRET, }); - // Load all default extractors including YouTube for search fallback - await this.player.extractors.loadDefault(); + // Load default extractors, prioritizing SoundCloud over YouTube + await this.player.extractors.loadDefault((ext) => { + return ext !== 'YouTubeExtractor'; + }); this.player.events.on('playerStart', (queue, track) => { queue.metadata?.channel?.send({ diff --git a/src/commands/music/play.js b/src/commands/music/play.js index 5fd1b9563..a1910c532 100644 --- a/src/commands/music/play.js +++ b/src/commands/music/play.js @@ -5,10 +5,10 @@ import { logger } from '../../utils/logger.js'; export default { data: new SlashCommandBuilder() .setName('play') - .setDescription('Play a song from Spotify or search by name') + .setDescription('Play a song from Spotify or SoundCloud') .addStringOption(option => option.setName('query') - .setDescription('Song name or Spotify URL') + .setDescription('Song name, Spotify URL, or SoundCloud URL') .setRequired(true) ), category: 'music', @@ -25,9 +25,12 @@ export default { const player = useMainPlayer(); try { - // Detect if it's a Spotify URL or a plain search - const isSpotifyUrl = query.includes('spotify.com'); - const searchEngine = isSpotifyUrl ? QueryType.SPOTIFY_SONG : QueryType.AUTO; + // Detect query type + let searchEngine = QueryType.AUTO; + if (query.includes('spotify.com/track')) searchEngine = QueryType.SPOTIFY_SONG; + else if (query.includes('spotify.com/playlist')) searchEngine = QueryType.SPOTIFY_PLAYLIST; + else if (query.includes('spotify.com/album')) searchEngine = QueryType.SPOTIFY_ALBUM; + else if (query.includes('soundcloud.com')) searchEngine = QueryType.SOUNDCLOUD_TRACK; const { track } = await player.play(voiceChannel, query, { searchEngine, @@ -59,7 +62,9 @@ export default { await interaction.editReply({ embeds: [embed] }); } catch (error) { logger.error('Play command error:', error); - await interaction.editReply({ content: `❌ Could not play that track. Try using a direct Spotify link or a different search term.` }); + await interaction.editReply({ + content: `❌ Could not find that track. Try:\n• A SoundCloud URL: \`https://soundcloud.com/...\`\n• A Spotify track URL: \`https://open.spotify.com/track/...\`\n• A different search term`, + }); } }, }; From 4e381ea9a9d056add5af780f64c02d05baae6819 Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Sat, 20 Jun 2026 14:49:44 -0600 Subject: [PATCH 017/115] removed music --- src/app.js | 60 +--------- src/commands/music/music.js | 224 ------------------------------------ src/commands/music/play.js | 70 ----------- 3 files changed, 1 insertion(+), 353 deletions(-) delete mode 100644 src/commands/music/music.js delete mode 100644 src/commands/music/play.js diff --git a/src/app.js b/src/app.js index e1ebcb57c..6662de0d6 100644 --- a/src/app.js +++ b/src/app.js @@ -3,8 +3,6 @@ import { Client, Collection, GatewayIntentBits } from 'discord.js'; import { REST } from '@discordjs/rest'; import express from 'express'; import cron from 'node-cron'; -import { Player } from 'discord-player'; -import { SpotifyExtractor } from '@discord-player/extractor'; import config from './config/application.js'; import { initializeDatabase } from './utils/database.js'; @@ -41,54 +39,6 @@ class TitanBot extends Client { this.cooldowns = new Collection(); this.db = null; this.rest = new REST({ version: '10' }).setToken(config.bot.token); - this.player = null; - } - - async initializePlayer() { - try { - this.player = new Player(this, { - skipFFmpeg: false, - }); - - await this.player.extractors.register(SpotifyExtractor, { - clientId: process.env.SPOTIFY_CLIENT_ID, - clientSecret: process.env.SPOTIFY_CLIENT_SECRET, - }); - - // Load default extractors, prioritizing SoundCloud over YouTube - await this.player.extractors.loadDefault((ext) => { - return ext !== 'YouTubeExtractor'; - }); - - this.player.events.on('playerStart', (queue, track) => { - queue.metadata?.channel?.send({ - content: `🎵 Now playing **${track.title}** by **${track.author}**`, - }).catch(() => {}); - }); - - this.player.events.on('emptyQueue', (queue) => { - queue.metadata?.channel?.send({ - content: '✅ Queue finished! Add more songs with `/play`.', - }).catch(() => {}); - }); - - this.player.events.on('emptyChannel', (queue) => { - queue.metadata?.channel?.send({ - content: '👋 Left the voice channel as it was empty.', - }).catch(() => {}); - }); - - this.player.events.on('error', (queue, error) => { - logger.error('Player error:', error); - queue.metadata?.channel?.send({ - content: `❌ An error occurred: ${error.message}`, - }).catch(() => {}); - }); - - startupLog('✅ Music player initialized'); - } catch (error) { - logger.error('Failed to initialize music player:', error); - } } async start() { @@ -117,9 +67,6 @@ class TitanBot extends Client { startupLog('Starting web server...'); this.startWebServer(); - - startupLog('Initializing music player...'); - await this.initializePlayer(); startupLog('Loading commands...'); await loadCommands(this); @@ -181,7 +128,7 @@ class TitanBot extends Client { }); const requestCounts = new Map(); - const windowMs = 60000; + const windowMs = 60000; const maxRequests = this.config.api?.rateLimit?.max || 100; app.use((req, res, next) => { @@ -388,11 +335,6 @@ class TitanBot extends Client { cron.getTasks().forEach(task => task.stop()); logger.info('✅ Cron jobs stopped'); - if (this.player) { - this.player.destroy(); - logger.info('✅ Music player destroyed'); - } - if (this.db && this.db.db) { logger.info('Closing database connection...'); try { diff --git a/src/commands/music/music.js b/src/commands/music/music.js deleted file mode 100644 index 882108bf3..000000000 --- a/src/commands/music/music.js +++ /dev/null @@ -1,224 +0,0 @@ -import { SlashCommandBuilder, EmbedBuilder } from 'discord.js'; -import { useQueue } from 'discord-player'; - -// Helper to check queue exists -function getQueue(interaction) { - const queue = useQueue(interaction.guild.id); - return queue; -} - -export const skip = { - data: new SlashCommandBuilder() - .setName('skip') - .setDescription('Skip the current song'), - category: 'music', - async execute(interaction) { - const queue = getQueue(interaction); - if (!queue || !queue.isPlaying()) { - return interaction.reply({ content: '❌ No music is playing!', ephemeral: true }); - } - const track = queue.currentTrack; - queue.node.skip(); - await interaction.reply({ - embeds: [ - new EmbedBuilder() - .setColor(0x1DB954) - .setDescription(`⏭️ Skipped **${track?.title || 'current track'}**`) - ] - }); - }, -}; - -export const stop = { - data: new SlashCommandBuilder() - .setName('stop') - .setDescription('Stop music and clear the queue'), - category: 'music', - async execute(interaction) { - const queue = getQueue(interaction); - if (!queue || !queue.isPlaying()) { - return interaction.reply({ content: '❌ No music is playing!', ephemeral: true }); - } - queue.delete(); - await interaction.reply({ - embeds: [ - new EmbedBuilder() - .setColor(0xE74C3C) - .setDescription('⏹️ Stopped music and cleared the queue.') - ] - }); - }, -}; - -export const pause = { - data: new SlashCommandBuilder() - .setName('pause') - .setDescription('Pause the current song'), - category: 'music', - async execute(interaction) { - const queue = getQueue(interaction); - if (!queue || !queue.isPlaying()) { - return interaction.reply({ content: '❌ No music is playing!', ephemeral: true }); - } - if (queue.node.isPaused()) { - return interaction.reply({ content: '⏸️ Music is already paused!', ephemeral: true }); - } - queue.node.pause(); - await interaction.reply({ - embeds: [ - new EmbedBuilder() - .setColor(0xF39C12) - .setDescription('⏸️ Paused the music.') - ] - }); - }, -}; - -export const resume = { - data: new SlashCommandBuilder() - .setName('resume') - .setDescription('Resume the paused song'), - category: 'music', - async execute(interaction) { - const queue = getQueue(interaction); - if (!queue) { - return interaction.reply({ content: '❌ No music in queue!', ephemeral: true }); - } - if (!queue.node.isPaused()) { - return interaction.reply({ content: '▶️ Music is already playing!', ephemeral: true }); - } - queue.node.resume(); - await interaction.reply({ - embeds: [ - new EmbedBuilder() - .setColor(0x1DB954) - .setDescription('▶️ Resumed the music.') - ] - }); - }, -}; - -export const queue = { - data: new SlashCommandBuilder() - .setName('queue') - .setDescription('Show the current music queue'), - category: 'music', - async execute(interaction) { - const q = getQueue(interaction); - if (!q || !q.isPlaying()) { - return interaction.reply({ content: '❌ No music is playing!', ephemeral: true }); - } - - const current = q.currentTrack; - const tracks = q.tracks.toArray().slice(0, 10); - - const embed = new EmbedBuilder() - .setColor(0x1DB954) - .setTitle('🎵 Music Queue') - .addFields({ - name: '▶️ Now Playing', - value: `**${current?.title}** by ${current?.author} (${current?.duration})`, - inline: false, - }); - - if (tracks.length > 0) { - embed.addFields({ - name: '📋 Up Next', - value: tracks.map((t, i) => `\`${i + 1}.\` **${t.title}** by ${t.author} (${t.duration})`).join('\n'), - inline: false, - }); - } else { - embed.addFields({ name: '📋 Up Next', value: 'Nothing in queue', inline: false }); - } - - embed.setFooter({ text: `${q.tracks.size} song(s) in queue` }); - - await interaction.reply({ embeds: [embed] }); - }, -}; - -export const nowplaying = { - data: new SlashCommandBuilder() - .setName('nowplaying') - .setDescription('Show the currently playing song'), - category: 'music', - async execute(interaction) { - const queue = getQueue(interaction); - if (!queue || !queue.isPlaying()) { - return interaction.reply({ content: '❌ No music is playing!', ephemeral: true }); - } - - const track = queue.currentTrack; - const progress = queue.node.createProgressBar(); - - const embed = new EmbedBuilder() - .setColor(0x1DB954) - .setTitle('🎵 Now Playing') - .setDescription(`**[${track.title}](${track.url})**`) - .addFields( - { name: 'Artist', value: track.author || 'Unknown', inline: true }, - { name: 'Duration', value: track.duration || 'Unknown', inline: true }, - { name: 'Requested by', value: `<@${track.requestedBy?.id || interaction.user.id}>`, inline: true }, - { name: 'Progress', value: progress || 'Unknown', inline: false }, - ) - .setThumbnail(track.thumbnail) - .setTimestamp(); - - await interaction.reply({ embeds: [embed] }); - }, -}; - -export const volume = { - data: new SlashCommandBuilder() - .setName('volume') - .setDescription('Set the music volume') - .addIntegerOption(option => - option.setName('level') - .setDescription('Volume level (1-100)') - .setMinValue(1) - .setMaxValue(100) - .setRequired(true) - ), - category: 'music', - async execute(interaction) { - const queue = getQueue(interaction); - if (!queue || !queue.isPlaying()) { - return interaction.reply({ content: '❌ No music is playing!', ephemeral: true }); - } - const level = interaction.options.getInteger('level'); - queue.node.setVolume(level); - await interaction.reply({ - embeds: [ - new EmbedBuilder() - .setColor(0x1DB954) - .setDescription(`🔊 Volume set to **${level}%**`) - ] - }); - }, -}; - -export const shuffle = { - data: new SlashCommandBuilder() - .setName('shuffle') - .setDescription('Shuffle the music queue'), - category: 'music', - async execute(interaction) { - const queue = getQueue(interaction); - if (!queue || queue.tracks.size < 2) { - return interaction.reply({ content: '❌ Not enough songs in the queue to shuffle!', ephemeral: true }); - } - queue.tracks.shuffle(); - await interaction.reply({ - embeds: [ - new EmbedBuilder() - .setColor(0x1DB954) - .setDescription('🔀 Queue shuffled!') - ] - }); - }, -}; - -// Default export for the command loader (exports all as array) -export default [ - skip, stop, pause, resume, queue, nowplaying, volume, shuffle -]; diff --git a/src/commands/music/play.js b/src/commands/music/play.js deleted file mode 100644 index a1910c532..000000000 --- a/src/commands/music/play.js +++ /dev/null @@ -1,70 +0,0 @@ -import { SlashCommandBuilder, EmbedBuilder } from 'discord.js'; -import { useMainPlayer, QueryType } from 'discord-player'; -import { logger } from '../../utils/logger.js'; - -export default { - data: new SlashCommandBuilder() - .setName('play') - .setDescription('Play a song from Spotify or SoundCloud') - .addStringOption(option => - option.setName('query') - .setDescription('Song name, Spotify URL, or SoundCloud URL') - .setRequired(true) - ), - category: 'music', - - async execute(interaction, config, client) { - await interaction.deferReply(); - - const voiceChannel = interaction.member?.voice?.channel; - if (!voiceChannel) { - return interaction.editReply({ content: '❌ You must be in a voice channel to play music!' }); - } - - const query = interaction.options.getString('query'); - const player = useMainPlayer(); - - try { - // Detect query type - let searchEngine = QueryType.AUTO; - if (query.includes('spotify.com/track')) searchEngine = QueryType.SPOTIFY_SONG; - else if (query.includes('spotify.com/playlist')) searchEngine = QueryType.SPOTIFY_PLAYLIST; - else if (query.includes('spotify.com/album')) searchEngine = QueryType.SPOTIFY_ALBUM; - else if (query.includes('soundcloud.com')) searchEngine = QueryType.SOUNDCLOUD_TRACK; - - const { track } = await player.play(voiceChannel, query, { - searchEngine, - nodeOptions: { - metadata: { - channel: interaction.channel, - }, - selfDeaf: true, - volume: 80, - leaveOnEmpty: true, - leaveOnEmptyCooldown: 30000, - leaveOnEnd: true, - leaveOnEndCooldown: 30000, - }, - }); - - const embed = new EmbedBuilder() - .setColor(0x1DB954) - .setTitle('🎵 Added to Queue') - .setDescription(`**[${track.title}](${track.url})**`) - .addFields( - { name: 'Artist', value: track.author || 'Unknown', inline: true }, - { name: 'Duration', value: track.duration || 'Unknown', inline: true }, - { name: 'Requested by', value: `<@${interaction.user.id}>`, inline: true }, - ) - .setThumbnail(track.thumbnail) - .setTimestamp(); - - await interaction.editReply({ embeds: [embed] }); - } catch (error) { - logger.error('Play command error:', error); - await interaction.editReply({ - content: `❌ Could not find that track. Try:\n• A SoundCloud URL: \`https://soundcloud.com/...\`\n• A Spotify track URL: \`https://open.spotify.com/track/...\`\n• A different search term`, - }); - } - }, -}; From 76ae5f596add04d5817012e15861a4ef673acdab Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Sat, 20 Jun 2026 14:56:14 -0600 Subject: [PATCH 018/115] New and replaced announce --- src/commands/Community/announcement.js | 334 +++++++++++++++++++++++++ src/events/guildMemberAdd.js | 219 ++-------------- src/events/guildMemberUpdate.js | 58 ++--- 3 files changed, 391 insertions(+), 220 deletions(-) create mode 100644 src/commands/Community/announcement.js diff --git a/src/commands/Community/announcement.js b/src/commands/Community/announcement.js new file mode 100644 index 000000000..1ac04ac6b --- /dev/null +++ b/src/commands/Community/announcement.js @@ -0,0 +1,334 @@ +import { + SlashCommandBuilder, + PermissionFlagsBits, + EmbedBuilder, + ChannelType, + MessageFlags, +} from 'discord.js'; +import { successEmbed, errorEmbed } from '../../utils/embeds.js'; +import { logger } from '../../utils/logger.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; +import { getFromDb, setInDb } from '../../utils/database.js'; + +const CONFIG_KEY = (guildId) => `announcement_config_${guildId}`; + +async function getConfig(client, guildId) { + return await getFromDb(CONFIG_KEY(guildId), { + channelId: null, + welcomeChannelId: null, + boostChannelId: null, + scheduledAnnouncements: [], + }); +} + +async function saveConfig(client, guildId, config) { + await setInDb(CONFIG_KEY(guildId), config); +} + +export default { + data: new SlashCommandBuilder() + .setName('announcement') + .setDescription('Manage server announcements') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) + + // /announcement setchannel + .addSubcommand(sub => + sub.setName('setchannel') + .setDescription('Set the channel for announcements') + .addChannelOption(opt => + opt.setName('channel') + .setDescription('The announcement channel') + .addChannelTypes(ChannelType.GuildText, ChannelType.GuildAnnouncement) + .setRequired(true) + ) + .addStringOption(opt => + opt.setName('type') + .setDescription('Type of channel to set') + .setRequired(true) + .addChoices( + { name: 'General Announcements', value: 'general' }, + { name: 'Welcome Messages', value: 'welcome' }, + { name: 'Boost Announcements', value: 'boost' }, + ) + ) + ) + + // /announcement send + .addSubcommand(sub => + sub.setName('send') + .setDescription('Send a manual announcement') + .addStringOption(opt => + opt.setName('title').setDescription('Announcement title').setRequired(true) + ) + .addStringOption(opt => + opt.setName('message').setDescription('Announcement message').setRequired(true) + ) + .addStringOption(opt => + opt.setName('ping') + .setDescription('Who to ping') + .setRequired(false) + .addChoices( + { name: '@everyone', value: 'everyone' }, + { name: '@here', value: 'here' }, + { name: 'A specific role', value: 'role' }, + { name: 'No ping', value: 'none' }, + ) + ) + .addRoleOption(opt => + opt.setName('role').setDescription('Role to ping (if ping is set to role)').setRequired(false) + ) + .addStringOption(opt => + opt.setName('color') + .setDescription('Embed color') + .setRequired(false) + .addChoices( + { name: 'Blue', value: '0x3498DB' }, + { name: 'Green', value: '0x2ECC71' }, + { name: 'Red', value: '0xE74C3C' }, + { name: 'Gold', value: '0xF1C40F' }, + { name: 'Purple', value: '0x9B59B6' }, + { name: 'White', value: '0xFFFFFF' }, + ) + ) + .addStringOption(opt => + opt.setName('image').setDescription('Image URL to attach to the announcement').setRequired(false) + ) + ) + + // /announcement schedule + .addSubcommand(sub => + sub.setName('schedule') + .setDescription('Schedule a recurring announcement') + .addStringOption(opt => + opt.setName('title').setDescription('Announcement title').setRequired(true) + ) + .addStringOption(opt => + opt.setName('message').setDescription('Announcement message').setRequired(true) + ) + .addStringOption(opt => + opt.setName('interval') + .setDescription('How often to post') + .setRequired(true) + .addChoices( + { name: 'Every hour', value: '0 * * * *' }, + { name: 'Every 6 hours', value: '0 */6 * * *' }, + { name: 'Every 12 hours', value: '0 */12 * * *' }, + { name: 'Daily (9am)', value: '0 9 * * *' }, + { name: 'Weekly (Monday 9am)', value: '0 9 * * 1' }, + ) + ) + .addStringOption(opt => + opt.setName('ping') + .setDescription('Who to ping') + .setRequired(false) + .addChoices( + { name: '@everyone', value: 'everyone' }, + { name: '@here', value: 'here' }, + { name: 'No ping', value: 'none' }, + ) + ) + ) + + // /announcement listschedules + .addSubcommand(sub => + sub.setName('listschedules') + .setDescription('List all scheduled announcements') + ) + + // /announcement deleteschedule + .addSubcommand(sub => + sub.setName('deleteschedule') + .setDescription('Delete a scheduled announcement') + .addIntegerOption(opt => + opt.setName('id').setDescription('Schedule ID to delete').setRequired(true) + ) + ), + + category: 'community', + + async execute(interaction, config, client) { + try { + const sub = interaction.options.getSubcommand(); + const guildConfig = await getConfig(client, interaction.guild.id); + + if (sub === 'setchannel') { + const channel = interaction.options.getChannel('channel'); + const type = interaction.options.getString('type'); + + if (type === 'general') guildConfig.channelId = channel.id; + if (type === 'welcome') guildConfig.welcomeChannelId = channel.id; + if (type === 'boost') guildConfig.boostChannelId = channel.id; + + await saveConfig(client, interaction.guild.id, guildConfig); + + const typeLabel = { general: 'General Announcements', welcome: 'Welcome Messages', boost: 'Boost Announcements' }[type]; + await InteractionHelper.universalReply(interaction, { + embeds: [successEmbed(`✅ Channel Set`, `**${typeLabel}** will now be posted in <#${channel.id}>`)], + }); + + } else if (sub === 'send') { + if (!guildConfig.channelId) { + throw new TitanBotError('No channel set', ErrorTypes.CONFIGURATION, 'Please set an announcement channel first with `/announcement setchannel`.', { subtype: 'missing_channel' }); + } + + const title = interaction.options.getString('title'); + const message = interaction.options.getString('message'); + const ping = interaction.options.getString('ping') || 'none'; + const role = interaction.options.getRole('role'); + const colorStr = interaction.options.getString('color') || '0x3498DB'; + const image = interaction.options.getString('image'); + + const channel = interaction.guild.channels.cache.get(guildConfig.channelId) + || await interaction.guild.channels.fetch(guildConfig.channelId).catch(() => null); + + if (!channel) { + throw new TitanBotError('Channel not found', ErrorTypes.CONFIGURATION, 'Announcement channel not found. Please set it again with `/announcement setchannel`.', { subtype: 'missing_channel' }); + } + + const embed = new EmbedBuilder() + .setTitle(`📢 ${title}`) + .setDescription(message) + .setColor(parseInt(colorStr, 16)) + .setFooter({ text: `Announced by ${interaction.user.tag}`, iconURL: interaction.user.displayAvatarURL() }) + .setTimestamp(); + + if (image) embed.setImage(image); + + let pingContent = ''; + if (ping === 'everyone') pingContent = '@everyone'; + else if (ping === 'here') pingContent = '@here'; + else if (ping === 'role' && role) pingContent = `<@&${role.id}>`; + + await channel.send({ content: pingContent || undefined, embeds: [embed] }); + + await InteractionHelper.universalReply(interaction, { + embeds: [successEmbed('✅ Announcement Sent', `Your announcement has been posted in <#${channel.id}>`)], + flags: MessageFlags.Ephemeral, + }); + + } else if (sub === 'schedule') { + if (!guildConfig.channelId) { + throw new TitanBotError('No channel set', ErrorTypes.CONFIGURATION, 'Please set an announcement channel first with `/announcement setchannel`.', { subtype: 'missing_channel' }); + } + + const title = interaction.options.getString('title'); + const message = interaction.options.getString('message'); + const interval = interaction.options.getString('interval'); + const ping = interaction.options.getString('ping') || 'none'; + + const schedule = { + id: Date.now(), + title, + message, + interval, + ping, + channelId: guildConfig.channelId, + createdBy: interaction.user.id, + createdAt: new Date().toISOString(), + }; + + if (!guildConfig.scheduledAnnouncements) guildConfig.scheduledAnnouncements = []; + guildConfig.scheduledAnnouncements.push(schedule); + await saveConfig(client, interaction.guild.id, guildConfig); + + // Register the cron job + registerSchedule(client, interaction.guild.id, schedule); + + const intervalLabel = { + '0 * * * *': 'Every hour', + '0 */6 * * *': 'Every 6 hours', + '0 */12 * * *': 'Every 12 hours', + '0 9 * * *': 'Daily at 9am', + '0 9 * * 1': 'Weekly on Monday at 9am', + }[interval]; + + await InteractionHelper.universalReply(interaction, { + embeds: [successEmbed('✅ Schedule Created', `**${title}** will be posted **${intervalLabel}** in <#${guildConfig.channelId}>`)], + }); + + } else if (sub === 'listschedules') { + const schedules = guildConfig.scheduledAnnouncements || []; + + if (schedules.length === 0) { + return InteractionHelper.universalReply(interaction, { + embeds: [new EmbedBuilder().setColor(0x3498DB).setDescription('No scheduled announcements.')], + }); + } + + const intervalLabels = { + '0 * * * *': 'Every hour', + '0 */6 * * *': 'Every 6 hours', + '0 */12 * * *': 'Every 12 hours', + '0 9 * * *': 'Daily at 9am', + '0 9 * * 1': 'Weekly on Monday', + }; + + const embed = new EmbedBuilder() + .setColor(0x3498DB) + .setTitle('📅 Scheduled Announcements') + .setDescription( + schedules.map(s => + `**ID:** \`${s.id}\`\n**Title:** ${s.title}\n**Interval:** ${intervalLabels[s.interval] || s.interval}\n**Channel:** <#${s.channelId}>\n` + ).join('\n') + ); + + await InteractionHelper.universalReply(interaction, { embeds: [embed] }); + + } else if (sub === 'deleteschedule') { + const id = interaction.options.getInteger('id'); + const schedules = guildConfig.scheduledAnnouncements || []; + const index = schedules.findIndex(s => s.id === id); + + if (index === -1) { + throw new TitanBotError('Schedule not found', ErrorTypes.USER_INPUT, `No schedule found with ID \`${id}\`. Use \`/announcement listschedules\` to see all schedules.`, { subtype: 'not_found' }); + } + + schedules.splice(index, 1); + guildConfig.scheduledAnnouncements = schedules; + await saveConfig(client, interaction.guild.id, guildConfig); + + await InteractionHelper.universalReply(interaction, { + embeds: [successEmbed('✅ Schedule Deleted', `Schedule \`${id}\` has been removed.`)], + }); + } + + } catch (error) { + logger.error('Announcement command error:', error); + await handleInteractionError(interaction, error, { subtype: 'announcement_failed' }); + } + }, +}; + +// Register a cron schedule for an announcement +export async function registerSchedule(client, guildId, schedule) { + try { + const { default: cron } = await import('node-cron'); + cron.schedule(schedule.interval, async () => { + try { + const guild = client.guilds.cache.get(guildId); + if (!guild) return; + + const channel = guild.channels.cache.get(schedule.channelId) + || await guild.channels.fetch(schedule.channelId).catch(() => null); + if (!channel) return; + + const embed = new EmbedBuilder() + .setTitle(`📢 ${schedule.title}`) + .setDescription(schedule.message) + .setColor(0x3498DB) + .setTimestamp(); + + let pingContent = ''; + if (schedule.ping === 'everyone') pingContent = '@everyone'; + else if (schedule.ping === 'here') pingContent = '@here'; + + await channel.send({ content: pingContent || undefined, embeds: [embed] }); + } catch (err) { + logger.error(`Error sending scheduled announcement ${schedule.id}:`, err); + } + }); + } catch (err) { + logger.error('Error registering schedule:', err); + } +} diff --git a/src/events/guildMemberAdd.js b/src/events/guildMemberAdd.js index 8bb4be116..4bf27f633 100644 --- a/src/events/guildMemberAdd.js +++ b/src/events/guildMemberAdd.js @@ -1,200 +1,35 @@ -import { Events, EmbedBuilder, PermissionFlagsBits } from 'discord.js'; -import { getColor } from '../config/bot.js'; -import { getGuildConfig } from '../services/guildConfig.js'; -import { getWelcomeConfig } from '../utils/database.js'; -import { formatWelcomeMessage } from '../utils/welcome.js'; -import { logEvent, EVENT_TYPES } from '../services/loggingService.js'; -import { getServerCounters, updateCounter } from '../services/serverstatsService.js'; -import { setBirthday as dbSetBirthday } from '../utils/database.js'; +import { Events, EmbedBuilder } from 'discord.js'; import { logger } from '../utils/logger.js'; +import { getFromDb } from '../utils/database.js'; + +const CONFIG_KEY = (guildId) => `announcement_config_${guildId}`; export default { name: Events.GuildMemberAdd, - once: false, - - async execute(member) { + async execute(member, client) { try { - const { guild, user } = member; - - const config = await getGuildConfig(member.client, guild.id); - - const welcomeConfig = await getWelcomeConfig(member.client, guild.id); - - const welcomeChannelId = welcomeConfig?.channelId; - - if (welcomeConfig?.enabled && welcomeChannelId) { - const channel = guild.channels.cache.get(welcomeChannelId); - if (channel?.isTextBased?.()) { - const me = guild.members.me; - const permissions = me ? channel.permissionsFor(me) : null; - if (!permissions?.has([PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages])) { - return; - } - - const formatData = { user, guild, member }; - const welcomeMessage = formatWelcomeMessage( - welcomeConfig.welcomeMessage || welcomeConfig.welcomeEmbed?.description || 'Welcome {user} to {server}!', - formatData - ); - - const messageContent = welcomeConfig.welcomePing ? user.toString() : null; - - const embedTitle = formatWelcomeMessage( - welcomeConfig.welcomeEmbed?.title || '🎉 Welcome!', - formatData - ); - const embedFooter = welcomeConfig.welcomeEmbed?.footer - ? formatWelcomeMessage(welcomeConfig.welcomeEmbed.footer, formatData) - : `Welcome to ${guild.name}!`; - - const canEmbed = permissions.has(PermissionFlagsBits.EmbedLinks); - - if (!canEmbed) { - await channel.send({ - content: messageContent || welcomeMessage - }); - } else { - const embed = new EmbedBuilder() - .setColor(welcomeConfig.welcomeEmbed?.color || getColor('success')) - .setTitle(embedTitle) - .setDescription(welcomeMessage) - .setThumbnail(user.displayAvatarURL()) - .addFields( - { name: 'User', value: `${user.tag} (${user.id})`, inline: true }, - { name: 'Member Count', value: guild.memberCount.toString(), inline: true } - ) - .setTimestamp() - .setFooter({ text: embedFooter }); - - if (welcomeConfig.welcomeImage) { - embed.setImage(welcomeConfig.welcomeImage); - } else if (welcomeConfig.welcomeEmbed?.image?.url) { - embed.setImage(welcomeConfig.welcomeEmbed.image.url); - } - - await channel.send({ - content: messageContent, - embeds: [embed] - }); - } - } - } - - if (welcomeConfig?.roleIds && welcomeConfig.roleIds.length > 0) { - const delay = welcomeConfig.autoRoleDelay || 0; - const singleRoleId = welcomeConfig.roleIds[0]; - - if (delay > 0) { - const timeout = setTimeout(async () => { - const role = guild.roles.cache.get(singleRoleId); - if (role) { - await assignRoleSafely(member, role); - } - }, delay * 1000); - if (typeof timeout.unref === 'function') { - timeout.unref(); - } - } else { - const role = guild.roles.cache.get(singleRoleId); - if (role) { - await assignRoleSafely(member, role); - } - } - } - - if (config?.verification?.enabled || config?.verification?.autoVerify?.enabled) { - await handleVerification(member, guild, config.verification, member.client); - } - - try { - await logEvent({ - client: member.client, - guildId: guild.id, - eventType: EVENT_TYPES.MEMBER_JOIN, - data: { - title: 'User joined', - lines: [ - `**User:** ${user.toString()} (${user.displayName !== user.username ? `@${user.displayName}` : user.tag})`, - `**ID:** \`${user.id}\``, - `**Created:** `, - `**Members:** ${guild.memberCount}`, - ], - quoted: false, - thumbnail: user.displayAvatarURL({ dynamic: true }), - userId: user.id, - } - }); - } catch (error) { - logger.debug('Error logging member join:', error); - } - - try { - const counters = await getServerCounters(member.client, guild.id); - for (const counter of counters) { - if (counter && counter.type && counter.channelId && counter.enabled !== false) { - await updateCounter(member.client, guild, counter); - } - } - } catch (error) { - logger.debug('Error updating counters on member join:', error); - } - - try { - const backupKey = `guild:${guild.id}:birthdays:left`; - const backup = (await member.client.db.get(backupKey)) || {}; - if (backup[user.id]) { - const { month, day } = backup[user.id]; - await dbSetBirthday(member.client, guild.id, user.id, month, day); - delete backup[user.id]; - await member.client.db.set(backupKey, backup); - logger.debug(`Birthday restored for user ${user.id} in guild ${guild.id}`); - } - } catch (error) { - logger.debug('Error restoring birthday on member join:', error); - } - + const config = await getFromDb(CONFIG_KEY(member.guild.id), {}); + if (!config.welcomeChannelId) return; + + const channel = member.guild.channels.cache.get(config.welcomeChannelId) + || await member.guild.channels.fetch(config.welcomeChannelId).catch(() => null); + if (!channel) return; + + const embed = new EmbedBuilder() + .setColor(0x2ECC71) + .setTitle(`👋 Welcome to ${member.guild.name}!`) + .setDescription(`Hey <@${member.id}>, welcome to **${member.guild.name}**! We're glad to have you here.\n\nMake sure to check out the rules and grab your roles!`) + .setThumbnail(member.user.displayAvatarURL({ dynamic: true, size: 256 })) + .addFields( + { name: 'Member', value: `<@${member.id}>`, inline: true }, + { name: 'Account Created', value: ``, inline: true }, + { name: 'Member Count', value: `#${member.guild.memberCount}`, inline: true }, + ) + .setTimestamp(); + + await channel.send({ embeds: [embed] }); } catch (error) { - logger.error('Error in guildMemberAdd event:', error); + logger.error('Error sending welcome message:', error); } - } + }, }; - -async function handleVerification(member, guild, verificationConfig, client) { - const { autoVerifyOnJoin } = await import('../services/verificationService.js'); - - try { - const result = await autoVerifyOnJoin(client, guild, member, verificationConfig); - - if (result.autoVerified) { - logger.info('User auto-verified on join', { - guildId: guild.id, - userId: member.id, - userTag: member.user.tag, - roleName: result.roleName, - criteria: result.criteria - }); - } else { - logger.debug('User not auto-verified on join', { - guildId: guild.id, - userId: member.id, - reason: result.reason - }); - } - - } catch (error) { - logger.error('Error in auto-verification for member', { - guildId: guild.id, - userId: member.id, - userTag: member.user.tag, - error: error.message - }); - } -} - -async function assignRoleSafely(member, role) { - try { - await member.roles.add(role); - } catch (error) { - logger.warn(`Failed to assign role ${role.id} to member ${member.id}:`, error); - } -} \ No newline at end of file diff --git a/src/events/guildMemberUpdate.js b/src/events/guildMemberUpdate.js index 80dc78806..de8cde60e 100644 --- a/src/events/guildMemberUpdate.js +++ b/src/events/guildMemberUpdate.js @@ -1,38 +1,40 @@ -import { Events } from 'discord.js'; -import { logEvent, EVENT_TYPES } from '../services/loggingService.js'; +import { Events, EmbedBuilder } from 'discord.js'; import { logger } from '../utils/logger.js'; +import { getFromDb } from '../utils/database.js'; + +const CONFIG_KEY = (guildId) => `announcement_config_${guildId}`; export default { name: Events.GuildMemberUpdate, - once: false, - - async execute(oldMember, newMember) { + async execute(oldMember, newMember, client) { try { - if (!newMember.guild) return; + // Check if member just boosted (didn't have premium before, now does) + const wasBosting = oldMember.premiumSince; + const isNowBoosting = newMember.premiumSince; + if (wasBosting || !isNowBoosting) return; + + const config = await getFromDb(CONFIG_KEY(newMember.guild.id), {}); + if (!config.boostChannelId) return; - if (oldMember.nickname !== newMember.nickname) { - await logEvent({ - client: newMember.client, - guildId: newMember.guild.id, - eventType: EVENT_TYPES.MEMBER_NAME_CHANGE, - data: { - title: 'Nickname changed', - lines: [ - `**User:** ${newMember.user.toString()} (${newMember.user.tag})`, - `**ID:** \`${newMember.user.id}\``, - `**Before:** ${oldMember.nickname || '*(no nickname)*'}`, - `**After:** ${newMember.nickname || '*(no nickname)*'}`, - ], - thumbnail: newMember.user.displayAvatarURL({ dynamic: true }), - userId: newMember.user.id, - } - }); + const channel = newMember.guild.channels.cache.get(config.boostChannelId) + || await newMember.guild.channels.fetch(config.boostChannelId).catch(() => null); + if (!channel) return; - return; - } + const embed = new EmbedBuilder() + .setColor(0xFF73FA) + .setTitle('🚀 New Server Boost!') + .setDescription(`<@${newMember.id}> just boosted the server! 🎉\nThanks for supporting **${newMember.guild.name}**!`) + .setThumbnail(newMember.user.displayAvatarURL({ dynamic: true, size: 256 })) + .addFields( + { name: 'Booster', value: `<@${newMember.id}>`, inline: true }, + { name: 'Total Boosts', value: `${newMember.guild.premiumSubscriptionCount}`, inline: true }, + { name: 'Boost Level', value: `Level ${newMember.guild.premiumTier}`, inline: true }, + ) + .setTimestamp(); + await channel.send({ content: `🚀 <@${newMember.id}> just boosted the server!`, embeds: [embed] }); } catch (error) { - logger.error('Error in guildMemberUpdate event:', error); + logger.error('Error sending boost announcement:', error); } - } -}; \ No newline at end of file + }, +}; From 5df7d06efcec241ac5c50469acdc01431d2509ce Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Sat, 20 Jun 2026 15:11:50 -0600 Subject: [PATCH 019/115] Added LOA Requests --- src/commands/Community/loa.js | 277 ++++++++++++++++++++++++ src/events/interactionCreate.js | 21 +- src/interactions/buttons/loa_approve.js | 142 ++++++++++++ 3 files changed, 439 insertions(+), 1 deletion(-) create mode 100644 src/commands/Community/loa.js create mode 100644 src/interactions/buttons/loa_approve.js diff --git a/src/commands/Community/loa.js b/src/commands/Community/loa.js new file mode 100644 index 000000000..e9a889377 --- /dev/null +++ b/src/commands/Community/loa.js @@ -0,0 +1,277 @@ +import { + SlashCommandBuilder, + PermissionFlagsBits, + EmbedBuilder, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + MessageFlags, +} from 'discord.js'; +import { successEmbed } from '../../utils/embeds.js'; +import { logger } from '../../utils/logger.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; +import { getFromDb, setInDb } from '../../utils/database.js'; + +const LOA_FORUM_CHANNEL_ID = '1517999241182576710'; +const LOA_ROLE_ID = '1513775663834992730'; + +function generateLoaId() { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + return Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join(''); +} + +export default { + data: new SlashCommandBuilder() + .setName('loa') + .setDescription('Leave of Absence request system') + + // /loa request + .addSubcommand(sub => + sub.setName('request') + .setDescription('Submit a Leave of Absence request') + .addStringOption(opt => + opt.setName('start_date') + .setDescription('Start date of your LOA (e.g. June 20, 2026)') + .setRequired(true) + ) + .addStringOption(opt => + opt.setName('end_date') + .setDescription('Expected return date (e.g. June 27, 2026)') + .setRequired(true) + ) + .addStringOption(opt => + opt.setName('reason') + .setDescription('Reason for your LOA') + .setRequired(true) + ) + .addStringOption(opt => + opt.setName('notes') + .setDescription('Any additional notes') + .setRequired(false) + ) + ) + + // /loa return + .addSubcommand(sub => + sub.setName('return') + .setDescription('Mark yourself as returned from LOA') + ) + + // /loa view + .addSubcommand(sub => + sub.setName('view') + .setDescription('View your current LOA status') + ) + + // /loa list (staff only) + .addSubcommand(sub => + sub.setName('list') + .setDescription('List all active LOAs (staff only)') + ), + + category: 'community', + + async execute(interaction, config, client) { + try { + const sub = interaction.options.getSubcommand(); + + if (sub === 'request') { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + const startDate = interaction.options.getString('start_date'); + const endDate = interaction.options.getString('end_date'); + const reason = interaction.options.getString('reason'); + const notes = interaction.options.getString('notes') || 'None'; + + // Check if user already has an active LOA + const existingLoa = await getFromDb(`loa_active_${interaction.guild.id}_${interaction.user.id}`, null); + if (existingLoa) { + throw new TitanBotError('Active LOA exists', ErrorTypes.USER_INPUT, 'You already have an active LOA request. Use `/loa view` to check its status.', { subtype: 'duplicate_loa' }); + } + + const loaId = generateLoaId(); + const now = new Date(); + + // Build the LOA embed + const embed = new EmbedBuilder() + .setTitle(`LOA Request — \`${loaId}\``) + .setColor(0xF39C12) + .setThumbnail(interaction.user.displayAvatarURL({ dynamic: true, size: 128 })) + .addFields( + { name: 'Member', value: `<@${interaction.user.id}> (\`${interaction.user.id}\`)`, inline: false }, + { name: 'Start Date', value: startDate, inline: true }, + { name: 'Return Date', value: endDate, inline: true }, + { name: 'Submitted', value: ``, inline: false }, + { name: 'Reason', value: reason, inline: false }, + { name: 'Additional Notes', value: notes, inline: false }, + { name: 'Status', value: '🟡 **Pending Review**', inline: false }, + ) + .setTimestamp(); + + // Approval buttons + const buttons = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`loa_approve_${loaId}_${interaction.user.id}`) + .setLabel('✅ Approve') + .setStyle(ButtonStyle.Success), + new ButtonBuilder() + .setCustomId(`loa_deny_${loaId}_${interaction.user.id}`) + .setLabel('❌ Deny') + .setStyle(ButtonStyle.Danger), + ); + + // Post to LOA forum channel + const forumChannel = interaction.guild.channels.cache.get(LOA_FORUM_CHANNEL_ID) + || await interaction.guild.channels.fetch(LOA_FORUM_CHANNEL_ID).catch(() => null); + + if (!forumChannel) { + throw new TitanBotError('Forum not found', ErrorTypes.CONFIGURATION, 'LOA forum channel not found.', { subtype: 'missing_channel' }); + } + + const forumPost = await forumChannel.threads.create({ + name: `LOA — ${interaction.user.username} — ${loaId}`, + message: { + embeds: [embed], + components: [buttons], + }, + }); + + // Store the LOA request + await setInDb(`loa_active_${interaction.guild.id}_${interaction.user.id}`, { + loaId, + userId: interaction.user.id, + startDate, + endDate, + reason, + notes, + status: 'pending', + threadId: forumPost.id, + submittedAt: now.toISOString(), + }); + + // Store by loaId for lookup + await setInDb(`loa_id_${interaction.guild.id}_${loaId}`, interaction.user.id); + + await InteractionHelper.universalReply(interaction, { + embeds: [ + new EmbedBuilder() + .setColor(0xF39C12) + .setTitle('📋 LOA Request Submitted') + .setDescription(`Your LOA request (\`${loaId}\`) has been submitted for review!\n\nYou'll be notified once it's approved or denied.`) + .addFields( + { name: 'Start Date', value: startDate, inline: true }, + { name: 'Return Date', value: endDate, inline: true }, + ) + .setTimestamp(), + ], + }); + + } else if (sub === 'return') { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + const loa = await getFromDb(`loa_active_${interaction.guild.id}_${interaction.user.id}`, null); + if (!loa) { + throw new TitanBotError('No active LOA', ErrorTypes.USER_INPUT, 'You don\'t have an active LOA.', { subtype: 'no_loa' }); + } + + // Remove the LOA role + const member = await interaction.guild.members.fetch(interaction.user.id).catch(() => null); + if (member) { + await member.roles.remove(LOA_ROLE_ID).catch(() => {}); + } + + // Update status + loa.status = 'returned'; + loa.returnedAt = new Date().toISOString(); + await setInDb(`loa_active_${interaction.guild.id}_${interaction.user.id}`, null); + await setInDb(`loa_returned_${interaction.guild.id}_${interaction.user.id}_${loa.loaId}`, loa); + + // Update forum thread if possible + if (loa.threadId) { + const thread = interaction.guild.channels.cache.get(loa.threadId) + || await interaction.guild.channels.fetch(loa.threadId).catch(() => null); + if (thread) { + await thread.send({ + embeds: [ + new EmbedBuilder() + .setColor(0x2ECC71) + .setDescription(`✅ <@${interaction.user.id}> has returned from LOA on .`) + ] + }).catch(() => {}); + await thread.setArchived(true).catch(() => {}); + } + } + + await InteractionHelper.universalReply(interaction, { + embeds: [successEmbed('✅ Welcome Back!', 'Your LOA has been marked as complete and your LOA role has been removed.')], + }); + + } else if (sub === 'view') { + const loa = await getFromDb(`loa_active_${interaction.guild.id}_${interaction.user.id}`, null); + if (!loa) { + return InteractionHelper.universalReply(interaction, { + embeds: [new EmbedBuilder().setColor(0x3498DB).setDescription('You have no active LOA request.')], + flags: MessageFlags.Ephemeral, + }); + } + + const statusEmoji = { pending: '🟡', approved: '🟢', denied: '🔴', returned: '⚪' }[loa.status] || '🟡'; + + const embed = new EmbedBuilder() + .setColor(0x3498DB) + .setTitle(`Your LOA — \`${loa.loaId}\``) + .addFields( + { name: 'Status', value: `${statusEmoji} ${loa.status.charAt(0).toUpperCase() + loa.status.slice(1)}`, inline: true }, + { name: 'Start Date', value: loa.startDate, inline: true }, + { name: 'Return Date', value: loa.endDate, inline: true }, + { name: 'Reason', value: loa.reason, inline: false }, + { name: 'Notes', value: loa.notes || 'None', inline: false }, + { name: 'Submitted', value: ``, inline: false }, + ) + .setTimestamp(); + + await InteractionHelper.universalReply(interaction, { embeds: [embed], flags: MessageFlags.Ephemeral }); + + } else if (sub === 'list') { + if (!interaction.member.permissions.has(PermissionFlagsBits.ManageRoles)) { + throw new TitanBotError('No permission', ErrorTypes.PERMISSIONS, 'You need Manage Roles permission to view all LOAs.', { subtype: 'missing_permission' }); + } + + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + // Get all active LOAs for this guild — scan known pattern + const loas = []; + for (const [, member] of interaction.guild.members.cache) { + const loa = await getFromDb(`loa_active_${interaction.guild.id}_${member.id}`, null); + if (loa) loas.push(loa); + } + + if (loas.length === 0) { + return InteractionHelper.universalReply(interaction, { + embeds: [new EmbedBuilder().setColor(0x3498DB).setDescription('No active LOAs at this time.')], + }); + } + + const statusEmoji = { pending: '🟡', approved: '🟢', denied: '🔴' }; + + const embed = new EmbedBuilder() + .setColor(0x3498DB) + .setTitle('📋 Active LOAs') + .setDescription( + loas.map(l => + `${statusEmoji[l.status] || '🟡'} <@${l.userId}> — \`${l.loaId}\` — **${l.startDate}** to **${l.endDate}**` + ).join('\n') + ) + .setFooter({ text: `${loas.length} active LOA(s)` }) + .setTimestamp(); + + await InteractionHelper.universalReply(interaction, { embeds: [embed] }); + } + + } catch (error) { + logger.error('LOA command error:', error); + await handleInteractionError(interaction, error, { subtype: 'loa_failed' }); + } + }, +}; diff --git a/src/events/interactionCreate.js b/src/events/interactionCreate.js index e9df9fb94..06aa1c180 100644 --- a/src/events/interactionCreate.js +++ b/src/events/interactionCreate.js @@ -285,6 +285,25 @@ export default { return; } + if (interaction.customId.startsWith('loa_')) { + const parts = interaction.customId.split('_'); + const buttonType = `${parts[0]}_${parts[1]}`; // e.g. "loa_approve" + const button = client.buttons.get(buttonType); + + if (button) { + try { + await button.execute(interaction, client, []); + } catch (error) { + await handleInteractionError(interaction, error, withTraceContext({ + type: 'button', + customId: interaction.customId, + handler: 'loa' + }, interactionTraceContext)); + } + } + return; + } + const [customId, ...args] = interaction.customId.split(':'); const button = client.buttons.get(customId); @@ -430,4 +449,4 @@ export default { } }); } -}; \ No newline at end of file +}; diff --git a/src/interactions/buttons/loa_approve.js b/src/interactions/buttons/loa_approve.js new file mode 100644 index 000000000..999d2a4f0 --- /dev/null +++ b/src/interactions/buttons/loa_approve.js @@ -0,0 +1,142 @@ +// src/interactions/buttons/loa_approve.js +// Handles LOA approve and deny buttons + +import { EmbedBuilder } from 'discord.js'; +import { logger } from '../../utils/logger.js'; +import { getFromDb, setInDb } from '../../utils/database.js'; + +const LOA_ROLE_ID = '1513775663834992730'; + +async function execute(interaction, client) { + try { + const parts = interaction.customId.split('_'); + const action = parts[1]; // 'approve' or 'deny' + const loaId = parts[2]; + const userId = parts[3]; + + if (!interaction.member.permissions.has(0x10000000n)) { // ManageRoles + return interaction.reply({ content: '❌ You need Manage Roles permission to approve/deny LOAs.', ephemeral: true }); + } + + const loa = await getFromDb(`loa_active_${interaction.guild.id}_${userId}`, null); + if (!loa) { + return interaction.reply({ content: '❌ This LOA request no longer exists or has already been processed.', ephemeral: true }); + } + + if (loa.status !== 'pending') { + return interaction.reply({ content: `❌ This LOA has already been **${loa.status}**.`, ephemeral: true }); + } + + const originalEmbed = interaction.message.embeds[0]; + const updatedEmbed = EmbedBuilder.from(originalEmbed); + + const member = await interaction.guild.members.fetch(userId).catch(() => null); + + if (action === 'approve') { + // Update status + loa.status = 'approved'; + loa.approvedBy = interaction.user.id; + loa.approvedAt = new Date().toISOString(); + await setInDb(`loa_active_${interaction.guild.id}_${userId}`, loa); + + // Give LOA role + if (member) { + await member.roles.add(LOA_ROLE_ID).catch(() => {}); + } + + // Update embed status field + const fields = updatedEmbed.data.fields || []; + const statusField = fields.find(f => f.name === 'Status'); + if (statusField) statusField.value = '🟢 **Approved**'; + + updatedEmbed.setColor(0x2ECC71); + updatedEmbed.addFields({ + name: 'Approved By', + value: `<@${interaction.user.id}> • `, + inline: false, + }); + + // Disable buttons + await interaction.message.edit({ embeds: [updatedEmbed], components: [] }); + + // Notify user via DM + if (member) { + await member.send({ + embeds: [ + new EmbedBuilder() + .setColor(0x2ECC71) + .setTitle('✅ LOA Approved') + .setDescription(`Your LOA request (\`${loaId}\`) in **${interaction.guild.name}** has been **approved**!\n\nEnjoy your time off. When you return, use \`/loa return\` to mark yourself back.`) + .addFields( + { name: 'Start Date', value: loa.startDate, inline: true }, + { name: 'Return Date', value: loa.endDate, inline: true }, + ) + .setTimestamp(), + ], + }).catch(() => {}); + } + + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(0x2ECC71) + .setDescription(`✅ Approved LOA \`${loaId}\` for <@${userId}>. They have been given the LOA role.`) + ], + ephemeral: true, + }); + + } else if (action === 'deny') { + // Update status + loa.status = 'denied'; + loa.deniedBy = interaction.user.id; + loa.deniedAt = new Date().toISOString(); + await setInDb(`loa_active_${interaction.guild.id}_${userId}`, null); + await setInDb(`loa_denied_${interaction.guild.id}_${userId}_${loaId}`, loa); + + // Update embed + const fields = updatedEmbed.data.fields || []; + const statusField = fields.find(f => f.name === 'Status'); + if (statusField) statusField.value = '🔴 **Denied**'; + + updatedEmbed.setColor(0xE74C3C); + updatedEmbed.addFields({ + name: 'Denied By', + value: `<@${interaction.user.id}> • `, + inline: false, + }); + + await interaction.message.edit({ embeds: [updatedEmbed], components: [] }); + + // Notify user via DM + if (member) { + await member.send({ + embeds: [ + new EmbedBuilder() + .setColor(0xE74C3C) + .setTitle('❌ LOA Denied') + .setDescription(`Your LOA request (\`${loaId}\`) in **${interaction.guild.name}** has been **denied**.\n\nIf you have questions, please contact staff.`) + .setTimestamp(), + ], + }).catch(() => {}); + } + + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(0xE74C3C) + .setDescription(`❌ Denied LOA \`${loaId}\` for <@${userId}>.`) + ], + ephemeral: true, + }); + } + + } catch (error) { + logger.error('Error handling LOA button:', error); + await interaction.reply({ content: 'An error occurred while processing this LOA.', ephemeral: true }).catch(() => {}); + } +} + +export default [ + { name: 'loa_approve', execute }, + { name: 'loa_deny', execute }, +]; From d3fffa036de996f4d7b75387921f278fa38d2eaf Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:04:12 -0600 Subject: [PATCH 020/115] Fixed some stuff --- src/config/bot.js | 2 +- src/events/messageCreate.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/config/bot.js b/src/config/bot.js index b7772e0ff..b25c15d0e 100644 --- a/src/config/bot.js +++ b/src/config/bot.js @@ -439,7 +439,7 @@ export const botConfig = { // Set any feature to `false` to disable it globally. features: { // Core systems. - economy: true, + economy: false, leveling: true, moderation: true, logging: true, diff --git a/src/events/messageCreate.js b/src/events/messageCreate.js index 4786cebb7..8d182b81c 100644 --- a/src/events/messageCreate.js +++ b/src/events/messageCreate.js @@ -47,7 +47,7 @@ export default { async function handleFAQ(message) { const faqs = { - "how do i get started": "Welcome! Check out our rules and grab your roles to get started!", + "How do I join": "Welcome! Create a ticket to register in a team! https://discord.com/channels/1382512078585200642/1514857141125644429", "how do i make a ticket": "Use the `/ticket` command to open a support ticket!", "what are the rules": "Please check the rules channel for our server rules!", "how do i level up": "Send messages in the server to earn XP and level up!", From 724f91af3abff115c0017874663f4cf9ce550e54 Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:07:13 -0600 Subject: [PATCH 021/115] Update messageCreate.js --- src/events/messageCreate.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/events/messageCreate.js b/src/events/messageCreate.js index 8d182b81c..cb81af7f6 100644 --- a/src/events/messageCreate.js +++ b/src/events/messageCreate.js @@ -48,7 +48,7 @@ export default { async function handleFAQ(message) { const faqs = { "How do I join": "Welcome! Create a ticket to register in a team! https://discord.com/channels/1382512078585200642/1514857141125644429", - "how do i make a ticket": "Use the `/ticket` command to open a support ticket!", + "how do i make a ticket": "To make a ticket go here https://discord.com/channels/1382512078585200642/1514857141125644429", "what are the rules": "Please check the rules channel for our server rules!", "how do i level up": "Send messages in the server to earn XP and level up!", "what commands are available": "Type `/` to see all available commands!", From d25803e03184e5798e8490a7b7a6bbab95d68984 Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:09:03 -0600 Subject: [PATCH 022/115] Update messageCreate.js --- src/events/messageCreate.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/events/messageCreate.js b/src/events/messageCreate.js index cb81af7f6..aecaa54d5 100644 --- a/src/events/messageCreate.js +++ b/src/events/messageCreate.js @@ -47,7 +47,7 @@ export default { async function handleFAQ(message) { const faqs = { - "How do I join": "Welcome! Create a ticket to register in a team! https://discord.com/channels/1382512078585200642/1514857141125644429", + "How do I join a team": "Welcome! Create a ticket to register in a team! https://discord.com/channels/1382512078585200642/1514857141125644429", "how do i make a ticket": "To make a ticket go here https://discord.com/channels/1382512078585200642/1514857141125644429", "what are the rules": "Please check the rules channel for our server rules!", "how do i level up": "Send messages in the server to earn XP and level up!", From 8ee9e83b273ac9171ee2e5935c0755ef6ac741cb Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:11:15 -0600 Subject: [PATCH 023/115] Update messageCreate.js --- src/events/messageCreate.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/events/messageCreate.js b/src/events/messageCreate.js index aecaa54d5..04aaaf323 100644 --- a/src/events/messageCreate.js +++ b/src/events/messageCreate.js @@ -47,8 +47,8 @@ export default { async function handleFAQ(message) { const faqs = { - "How do I join a team": "Welcome! Create a ticket to register in a team! https://discord.com/channels/1382512078585200642/1514857141125644429", - "how do i make a ticket": "To make a ticket go here https://discord.com/channels/1382512078585200642/1514857141125644429", + "How do I join a team": "Welcome! Create a ticket to register in a team https://discord.com/channels/1382512078585200642/1514857141125644429", + "how do I join team syne": "Go here to join https://discord.com/channels/1382512078585200642/1514857141125644429", "what are the rules": "Please check the rules channel for our server rules!", "how do i level up": "Send messages in the server to earn XP and level up!", "what commands are available": "Type `/` to see all available commands!", From 0894b6c6cf99fc2ded162845abbefec508d2b949 Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:15:40 -0600 Subject: [PATCH 024/115] Update messageCreate.js --- src/events/messageCreate.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/events/messageCreate.js b/src/events/messageCreate.js index 04aaaf323..ecadea8c5 100644 --- a/src/events/messageCreate.js +++ b/src/events/messageCreate.js @@ -48,7 +48,7 @@ export default { async function handleFAQ(message) { const faqs = { "How do I join a team": "Welcome! Create a ticket to register in a team https://discord.com/channels/1382512078585200642/1514857141125644429", - "how do I join team syne": "Go here to join https://discord.com/channels/1382512078585200642/1514857141125644429", + "how do I join team syne": "Create a ticket here https://discord.com/channels/1382512078585200642/1514857141125644429", "what are the rules": "Please check the rules channel for our server rules!", "how do i level up": "Send messages in the server to earn XP and level up!", "what commands are available": "Type `/` to see all available commands!", From 516974edfa96508cd914e1f65b0088eef3c901c3 Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:18:36 -0600 Subject: [PATCH 025/115] Update messageCreate.js --- src/events/messageCreate.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/events/messageCreate.js b/src/events/messageCreate.js index ecadea8c5..3bb9abf2c 100644 --- a/src/events/messageCreate.js +++ b/src/events/messageCreate.js @@ -47,8 +47,8 @@ export default { async function handleFAQ(message) { const faqs = { - "How do I join a team": "Welcome! Create a ticket to register in a team https://discord.com/channels/1382512078585200642/1514857141125644429", - "how do I join team syne": "Create a ticket here https://discord.com/channels/1382512078585200642/1514857141125644429", + "How do I join": "Create a ticket https://discord.com/channels/1382512078585200642/1514857141125644429", + "how do i make a ticket": "To join click here https://discord.com/channels/1382512078585200642/1514857141125644429", "what are the rules": "Please check the rules channel for our server rules!", "how do i level up": "Send messages in the server to earn XP and level up!", "what commands are available": "Type `/` to see all available commands!", From 1d8dbd580b7ff432c8ff3e536db899c2ff9874f3 Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:26:09 -0600 Subject: [PATCH 026/115] Update messageCreate.js --- src/events/messageCreate.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/events/messageCreate.js b/src/events/messageCreate.js index 3bb9abf2c..9f7f978d0 100644 --- a/src/events/messageCreate.js +++ b/src/events/messageCreate.js @@ -48,7 +48,7 @@ export default { async function handleFAQ(message) { const faqs = { "How do I join": "Create a ticket https://discord.com/channels/1382512078585200642/1514857141125644429", - "how do i make a ticket": "To join click here https://discord.com/channels/1382512078585200642/1514857141125644429", + "how do i make a ticket": "To join team syne click here https://discord.com/channels/1382512078585200642/1514857141125644429", "what are the rules": "Please check the rules channel for our server rules!", "how do i level up": "Send messages in the server to earn XP and level up!", "what commands are available": "Type `/` to see all available commands!", @@ -59,7 +59,7 @@ async function handleFAQ(message) { const content = message.content.toLowerCase(); for (const [keyword, reply] of Object.entries(faqs)) { - if (content.includes(keyword)) { + if (content.includes(keyword.toLowerCase())) { await message.reply(reply); return true; } From af5d20dfd85b2003666bf8f0c6910df27cc3c0eb Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:29:39 -0600 Subject: [PATCH 027/115] Update messageCreate.js --- src/events/messageCreate.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/events/messageCreate.js b/src/events/messageCreate.js index 9f7f978d0..02024dd02 100644 --- a/src/events/messageCreate.js +++ b/src/events/messageCreate.js @@ -47,7 +47,7 @@ export default { async function handleFAQ(message) { const faqs = { - "How do I join": "Create a ticket https://discord.com/channels/1382512078585200642/1514857141125644429", + "How do I join team syne": "Create a ticket https://discord.com/channels/1382512078585200642/1396618369209340084", "how do i make a ticket": "To join team syne click here https://discord.com/channels/1382512078585200642/1514857141125644429", "what are the rules": "Please check the rules channel for our server rules!", "how do i level up": "Send messages in the server to earn XP and level up!", From 5943eb233ce41cdf93dc97b41312b38ac237a048 Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:32:51 -0600 Subject: [PATCH 028/115] Update messageCreate.js --- src/events/messageCreate.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/events/messageCreate.js b/src/events/messageCreate.js index 02024dd02..92e0bab21 100644 --- a/src/events/messageCreate.js +++ b/src/events/messageCreate.js @@ -49,10 +49,10 @@ async function handleFAQ(message) { const faqs = { "How do I join team syne": "Create a ticket https://discord.com/channels/1382512078585200642/1396618369209340084", "how do i make a ticket": "To join team syne click here https://discord.com/channels/1382512078585200642/1514857141125644429", - "what are the rules": "Please check the rules channel for our server rules!", + "what are the rules": "Please check the rules channel for our server rules! https://discord.com/channels/1382512078585200642/1382512079486845073", "how do i level up": "Send messages in the server to earn XP and level up!", "what commands are available": "Type `/` to see all available commands!", - "how do i get roles": "Head over to the roles channel and pick the ones you want!", + "how do i get roles": "Head over to the roles channel and pick the ones you want! https://discord.com/channels/1382512078585200642/1514096776100053172", "who made this bot": "This bot was built with TeamSyne — a powerful all-in-one Discord assistant!", }; From a44554316e05b48e713eb7e9d0fdbebb41e3a2e5 Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:41:28 -0600 Subject: [PATCH 029/115] Update messageCreate.js --- src/events/messageCreate.js | 62 ++++++++++++++++++++++++++++++++++--- 1 file changed, 58 insertions(+), 4 deletions(-) diff --git a/src/events/messageCreate.js b/src/events/messageCreate.js index 92e0bab21..61abd0780 100644 --- a/src/events/messageCreate.js +++ b/src/events/messageCreate.js @@ -1,4 +1,4 @@ -import { Events } from 'discord.js'; +import { Events } from 'discord.js'; import { logger } from '../utils/logger.js'; import { getLevelingConfig, getUserLevelData } from '../services/leveling.js'; import { addXp } from '../services/xpSystem.js'; @@ -56,10 +56,10 @@ async function handleFAQ(message) { "who made this bot": "This bot was built with TeamSyne — a powerful all-in-one Discord assistant!", }; - const content = message.content.toLowerCase(); + const content = message.content; for (const [keyword, reply] of Object.entries(faqs)) { - if (content.includes(keyword.toLowerCase())) { + if (isFaqMatch(content, keyword)) { await message.reply(reply); return true; } @@ -68,6 +68,60 @@ async function handleFAQ(message) { return false; } +function normalizeText(text) { + return text + .toLowerCase() + .replace(/[^\u0000-\u007F]+/g, '') + .replace(/[^\\n\\w\\s]/g, ' ') + .replace(/\\s+/g, ' ') + .trim(); +} + +function getLevenshteinDistance(a, b) { + const matrix = Array.from({ length: b.length + 1 }, () => []); + for (let i = 0; i <= b.length; i += 1) { + matrix[i][0] = i; + } + for (let j = 0; j <= a.length; j += 1) { + matrix[0][j] = j; + } + + for (let i = 1; i <= b.length; i += 1) { + for (let j = 1; j <= a.length; j += 1) { + matrix[i][j] = Math.min( + matrix[i - 1][j] + 1, + matrix[i][j - 1] + 1, + matrix[i - 1][j - 1] + (a[j - 1] === b[i - 1] ? 0 : 1), + ); + } + } + + return matrix[b.length][a.length]; +} + +function isFaqMatch(messageContent, keyword) { + const normalizedContent = normalizeText(messageContent); + const normalizedKeyword = normalizeText(keyword); + + if (normalizedContent.includes(normalizedKeyword)) { + return true; + } + + const messageWords = normalizedContent.split(' ').filter(Boolean); + const keywordWords = normalizedKeyword.split(' ').filter(Boolean); + let matchedWords = 0; + + for (const keywordWord of keywordWords) { + const maxDistance = Math.max(1, Math.floor(keywordWord.length * 0.25)); + const bestDistance = messageWords.reduce((best, word) => Math.min(best, getLevenshteinDistance(keywordWord, word)), Infinity); + if (bestDistance <= maxDistance) { + matchedWords += 1; + } + } + + return matchedWords >= Math.ceil(keywordWords.length * 0.75); +} + async function handlePrefixCommand(message, client) { try { const guildConfig = await getGuildConfig(client, message.guild.id); @@ -245,4 +299,4 @@ async function handleLeveling(message, client) { } catch (error) { logger.error('Error handling leveling for message:', error); } -} \ No newline at end of file +} From 99c770585fd11d225ee529ed0379ee369d556716 Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:50:15 -0600 Subject: [PATCH 030/115] Update messageCreate.js --- src/events/messageCreate.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/events/messageCreate.js b/src/events/messageCreate.js index 61abd0780..07b556f5d 100644 --- a/src/events/messageCreate.js +++ b/src/events/messageCreate.js @@ -72,8 +72,8 @@ function normalizeText(text) { return text .toLowerCase() .replace(/[^\u0000-\u007F]+/g, '') - .replace(/[^\\n\\w\\s]/g, ' ') - .replace(/\\s+/g, ' ') + .replace(/[^\n\w\s]/g, ' ') + .replace(/\s+/g, ' ') .trim(); } @@ -109,6 +109,9 @@ function isFaqMatch(messageContent, keyword) { const messageWords = normalizedContent.split(' ').filter(Boolean); const keywordWords = normalizedKeyword.split(' ').filter(Boolean); + if (messageWords.length > keywordWords.length + 2) { + return false; + } let matchedWords = 0; for (const keywordWord of keywordWords) { @@ -119,7 +122,7 @@ function isFaqMatch(messageContent, keyword) { } } - return matchedWords >= Math.ceil(keywordWords.length * 0.75); + return matchedWords >= Math.max(2, keywordWords.length - 1); } async function handlePrefixCommand(message, client) { From 547449024a1610735fe47c292be1b60ed5230b4e Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Sat, 20 Jun 2026 17:33:41 -0600 Subject: [PATCH 031/115] Update bot.js --- src/config/bot.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/config/bot.js b/src/config/bot.js index b25c15d0e..c3defc70e 100644 --- a/src/config/bot.js +++ b/src/config/bot.js @@ -11,7 +11,7 @@ export const botConfig = { // - "invisible" = appears offline presence: { // Current online state shown on Discord. - status: "online", + status: "dnd", // Activity lines shown under the bot name. // `type` number mapping from Discord: @@ -139,7 +139,7 @@ export const botConfig = { }, footer: { // Default footer text used in bot embeds. - text: "Titan Bot", + text: "KJ'S BOT", // Footer icon URL (null = no icon). icon: null, }, From a923fcc1933530ae1df3cb28414de14dc3eb81ab Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Sat, 20 Jun 2026 17:49:51 -0600 Subject: [PATCH 032/115] Update announcement.js --- src/commands/Community/announcement.js | 37 ++++++++++++++------------ 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/src/commands/Community/announcement.js b/src/commands/Community/announcement.js index 1ac04ac6b..f80f9bb08 100644 --- a/src/commands/Community/announcement.js +++ b/src/commands/Community/announcement.js @@ -26,6 +26,19 @@ async function saveConfig(client, guildId, config) { await setInDb(CONFIG_KEY(guildId), config); } +function buildAnnouncementEmbed(title, message, color, image) { + const embed = new EmbedBuilder() + .setTitle(title) + .setDescription(message) + .setColor(color); + + if (image) { + embed.setImage(image); + } + + return embed; +} + export default { data: new SlashCommandBuilder() .setName('announcement') @@ -186,21 +199,14 @@ export default { throw new TitanBotError('Channel not found', ErrorTypes.CONFIGURATION, 'Announcement channel not found. Please set it again with `/announcement setchannel`.', { subtype: 'missing_channel' }); } - const embed = new EmbedBuilder() - .setTitle(`📢 ${title}`) - .setDescription(message) - .setColor(parseInt(colorStr, 16)) - .setFooter({ text: `Announced by ${interaction.user.tag}`, iconURL: interaction.user.displayAvatarURL() }) - .setTimestamp(); + const embed = buildAnnouncementEmbed(title, message, parseInt(colorStr, 16), image); - if (image) embed.setImage(image); + const announcementPayload = { embeds: [embed] }; + if (ping === 'everyone') announcementPayload.content = '@everyone'; + else if (ping === 'here') announcementPayload.content = '@here'; + else if (ping === 'role' && role) announcementPayload.content = `<@&${role.id}>`; - let pingContent = ''; - if (ping === 'everyone') pingContent = '@everyone'; - else if (ping === 'here') pingContent = '@here'; - else if (ping === 'role' && role) pingContent = `<@&${role.id}>`; - - await channel.send({ content: pingContent || undefined, embeds: [embed] }); + await channel.send(announcementPayload); await InteractionHelper.universalReply(interaction, { embeds: [successEmbed('✅ Announcement Sent', `Your announcement has been posted in <#${channel.id}>`)], @@ -313,10 +319,7 @@ export async function registerSchedule(client, guildId, schedule) { || await guild.channels.fetch(schedule.channelId).catch(() => null); if (!channel) return; - const embed = new EmbedBuilder() - .setTitle(`📢 ${schedule.title}`) - .setDescription(schedule.message) - .setColor(0x3498DB) + const embed = buildAnnouncementEmbed(`📢 ${schedule.title}`, schedule.message, 0x3498DB) .setTimestamp(); let pingContent = ''; From 149abe1e6b9f524a0e21e5209298b3d7249c9f75 Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Sat, 20 Jun 2026 17:54:37 -0600 Subject: [PATCH 033/115] Update announcement.js --- src/commands/Community/announcement.js | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/commands/Community/announcement.js b/src/commands/Community/announcement.js index f80f9bb08..82807a9e2 100644 --- a/src/commands/Community/announcement.js +++ b/src/commands/Community/announcement.js @@ -26,10 +26,25 @@ async function saveConfig(client, guildId, config) { await setInDb(CONFIG_KEY(guildId), config); } +function formatAnnouncementMessage(message) { + const cleaned = String(message || '') + .split(/\r?\n/) + .map(line => line.trim()) + .filter(line => line.length > 0 && !/^[\s\-_=·]{3,}$/.test(line)) + .map(line => line.replace(/^[-*]\s+/, '• ')) + .map(line => line.replace(/·/g, '•')) + .map(line => line.replace(/\s*—\s*/g, ' — ')) + .map(line => line.replace(/\s{2,}/g, ' ')) + .join('\n') + .replace(/\n{3,}/g, '\n\n'); + + return cleaned; +} + function buildAnnouncementEmbed(title, message, color, image) { const embed = new EmbedBuilder() .setTitle(title) - .setDescription(message) + .setDescription(formatAnnouncementMessage(message)) .setColor(color); if (image) { From f95b1ea3fb3dd43b5cde2cebf4c3eaf0c3694490 Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:18:50 -0600 Subject: [PATCH 034/115] Updated --- src/commands/Ticket/modules/ticket_panels.js | 176 ++++++++ src/commands/Ticket/ticket.js | 424 ++++++++++--------- src/events/interactionCreate.js | 36 +- src/handlers/ticketButtons.js | 112 ++++- src/interactions/buttons/ticket.js | 6 +- 5 files changed, 540 insertions(+), 214 deletions(-) create mode 100644 src/commands/Ticket/modules/ticket_panels.js diff --git a/src/commands/Ticket/modules/ticket_panels.js b/src/commands/Ticket/modules/ticket_panels.js new file mode 100644 index 000000000..5d84e5c9b --- /dev/null +++ b/src/commands/Ticket/modules/ticket_panels.js @@ -0,0 +1,176 @@ +// src/commands/Ticket/modules/ticket_panels.js +// Multi-panel management module + +import { + SlashCommandBuilder, + PermissionFlagsBits, + ChannelType, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + EmbedBuilder, + MessageFlags, +} from 'discord.js'; +import { getColor } from '../../../config/bot.js'; +import { createEmbed, successEmbed } from '../../../utils/embeds.js'; +import { logger } from '../../../utils/logger.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { replyUserError, ErrorTypes, TitanBotError } from '../../../utils/errorHandler.js'; +import { getFromDb, setInDb } from '../../../utils/database.js'; + +const PANELS_KEY = (guildId) => `ticket_panels_${guildId}`; + +export async function getPanels(guildId) { + return await getFromDb(PANELS_KEY(guildId), []); +} + +export async function savePanels(guildId, panels) { + await setInDb(PANELS_KEY(guildId), panels); +} + +export function generatePanelId() { + const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; + return Array.from({ length: 8 }, () => chars[Math.floor(Math.random() * chars.length)]).join(''); +} + +export async function handlePanelAdd(interaction, client) { + const panelChannel = interaction.options.getChannel('panel_channel'); + const panelMessage = interaction.options.getString('panel_message'); + const buttonLabel = interaction.options.getString('button_label') || 'Create Ticket'; + const panelTitle = interaction.options.getString('panel_title') || 'Support Tickets'; + const category = interaction.options.getChannel('category'); + const closedCategory = interaction.options.getChannel('closed_category'); + const staffRole = interaction.options.getRole('staff_role'); + const maxTickets = interaction.options.getInteger('max_tickets_per_user') || 3; + const dmOnClose = interaction.options.getBoolean('dm_on_close') !== false; + + const panelId = generatePanelId(); + + // Build and send the panel embed + const embed = new EmbedBuilder() + .setTitle(panelTitle) + .setDescription(panelMessage) + .setColor(getColor('info')); + + const button = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`create_ticket_${panelId}`) + .setLabel(buttonLabel) + .setStyle(ButtonStyle.Primary) + .setEmoji('📩'), + ); + + const sentPanel = await panelChannel.send({ embeds: [embed], components: [button] }); + + // Save panel config + const panels = await getPanels(interaction.guildId); + panels.push({ + panelId, + panelTitle, + panelMessage, + buttonLabel, + channelId: panelChannel.id, + messageId: sentPanel.id, + categoryId: category?.id || null, + closedCategoryId: closedCategory?.id || null, + staffRoleId: staffRole?.id || null, + maxTicketsPerUser: maxTickets, + dmOnClose, + createdBy: interaction.user.id, + createdAt: new Date().toISOString(), + }); + await savePanels(interaction.guildId, panels); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [ + successEmbed( + '✅ Panel Created', + `Panel \`${panelId}\` has been posted in <#${panelChannel.id}>.\n\n` + + `**Title:** ${panelTitle}\n` + + `**Button:** ${buttonLabel}\n` + + `**Category:** ${category ? category.name : 'Not set'}\n` + + `**Staff Role:** ${staffRole ? staffRole.name : 'Not set'}\n` + + `**Max Tickets/User:** ${maxTickets}\n` + + `**DM on Close:** ${dmOnClose ? 'Enabled' : 'Disabled'}` + ), + ], + }); + + logger.info('Ticket panel created', { + panelId, + guildId: interaction.guildId, + channelId: panelChannel.id, + userId: interaction.user.id, + }); +} + +export async function handlePanelList(interaction, client) { + const panels = await getPanels(interaction.guildId); + + if (panels.length === 0) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [ + new EmbedBuilder() + .setColor(getColor('info')) + .setTitle('🎫 Ticket Panels') + .setDescription('No panels have been created yet.\nUse `/ticket panel add` to create one.'), + ], + }); + } + + const embed = new EmbedBuilder() + .setColor(getColor('info')) + .setTitle('🎫 Ticket Panels') + .setDescription(`**${panels.length}** panel(s) configured for this server.`) + .setTimestamp(); + + for (const panel of panels) { + embed.addFields({ + name: `${panel.panelTitle} — \`${panel.panelId}\``, + value: [ + `**Channel:** <#${panel.channelId}>`, + `**Button:** ${panel.buttonLabel}`, + `**Category:** ${panel.categoryId ? `<#${panel.categoryId}>` : 'Not set'}`, + `**Staff Role:** ${panel.staffRoleId ? `<@&${panel.staffRoleId}>` : 'Not set'}`, + `**Max Tickets:** ${panel.maxTicketsPerUser}`, + ].join('\n'), + inline: false, + }); + } + + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); +} + +export async function handlePanelDelete(interaction, client) { + const panelId = interaction.options.getString('panel_id'); + const panels = await getPanels(interaction.guildId); + const index = panels.findIndex(p => p.panelId === panelId); + + if (index === -1) { + return replyUserError(interaction, { + type: ErrorTypes.UNKNOWN, + message: `No panel found with ID \`${panelId}\`. Use \`/ticket panel list\` to see all panels.`, + }); + } + + const panel = panels[index]; + + // Try to delete the panel message + try { + const channel = interaction.guild.channels.cache.get(panel.channelId) + || await interaction.guild.channels.fetch(panel.channelId).catch(() => null); + if (channel && panel.messageId) { + const msg = await channel.messages.fetch(panel.messageId).catch(() => null); + if (msg) await msg.delete().catch(() => {}); + } + } catch (err) { + logger.warn(`Could not delete panel message for ${panelId}:`, err.message); + } + + panels.splice(index, 1); + await savePanels(interaction.guildId, panels); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [successEmbed('✅ Panel Deleted', `Panel \`${panelId}\` (${panel.panelTitle}) has been removed.`)], + }); +} diff --git a/src/commands/Ticket/ticket.js b/src/commands/Ticket/ticket.js index d7f4e9a2c..c40ce31e3 100644 --- a/src/commands/Ticket/ticket.js +++ b/src/commands/Ticket/ticket.js @@ -5,107 +5,162 @@ import { getGuildConfig } from '../../services/guildConfig.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { logger } from '../../utils/logger.js'; import { handleInteractionError, replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; - import ticketConfig from './modules/ticket_dashboard.js'; +import { handlePanelAdd, handlePanelList, handlePanelDelete } from './modules/ticket_panels.js'; export default { data: new SlashCommandBuilder() .setName("ticket") .setDescription("Manages the server's ticket system.") .setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels) + + // Existing setup subcommand .addSubcommand((subcommand) => subcommand .setName("setup") - .setDescription( - "Sets up the ticket creation panel in a specified channel.", - ) + .setDescription("Sets up the ticket creation panel in a specified channel.") .addChannelOption((option) => - option -.setName("panel_channel") - .setDescription( - "The channel where the ticket panel will be sent.", - ) + option.setName("panel_channel") + .setDescription("The channel where the ticket panel will be sent.") .addChannelTypes(ChannelType.GuildText) .setRequired(true), ) - .addStringOption((option) => - option - .setName("panel_message") - .setDescription( - "The main message/description for the ticket panel.", - ) + option.setName("panel_message") + .setDescription("The main message/description for the ticket panel.") .setRequired(true), ) .addStringOption((option) => - option - .setName("button_label") - .setDescription( - "The label for the ticket creation button (default: Create Ticket)", - ) + option.setName("button_label") + .setDescription("The label for the ticket creation button (default: Create Ticket)") .setRequired(false), ) .addChannelOption((option) => - option - .setName("category") - .setDescription( - "The category where new tickets will be created (optional).", - ) + option.setName("category") + .setDescription("The category where new tickets will be created (optional).") .addChannelTypes(ChannelType.GuildCategory) .setRequired(false), ) .addChannelOption((option) => - option - .setName("closed_category") - .setDescription( - "The category where closed tickets will be moved (optional).", - ) + option.setName("closed_category") + .setDescription("The category where closed tickets will be moved (optional).") .addChannelTypes(ChannelType.GuildCategory) .setRequired(false), ) .addRoleOption((option) => - option - .setName("staff_role") - .setDescription( - "The role that can access tickets (optional).", - ) + option.setName("staff_role") + .setDescription("The role that can access tickets (optional).") .setRequired(false), ) .addIntegerOption((option) => - option - .setName("max_tickets_per_user") + option.setName("max_tickets_per_user") .setDescription("Maximum number of tickets a user can create (default: 3)") .setMinValue(1) .setMaxValue(10) .setRequired(false), ) .addBooleanOption((option) => - option - .setName("dm_on_close") + option.setName("dm_on_close") .setDescription("Send DM to user when their ticket is closed (default: true)") .setRequired(false), ), ) + + // Existing dashboard subcommand .addSubcommand((subcommand) => subcommand .setName("dashboard") .setDescription("Open the interactive ticket system dashboard"), + ) + + // New panel subcommand group + .addSubcommandGroup((group) => + group + .setName("panel") + .setDescription("Manage multiple ticket panels") + + // panel add + .addSubcommand((sub) => + sub.setName("add") + .setDescription("Create a new ticket panel in a channel") + .addChannelOption((opt) => + opt.setName("panel_channel") + .setDescription("Channel to post the panel in") + .addChannelTypes(ChannelType.GuildText) + .setRequired(true), + ) + .addStringOption((opt) => + opt.setName("panel_message") + .setDescription("Description shown on the panel") + .setRequired(true), + ) + .addStringOption((opt) => + opt.setName("panel_title") + .setDescription("Title of the panel embed (default: Support Tickets)") + .setRequired(false), + ) + .addStringOption((opt) => + opt.setName("button_label") + .setDescription("Label on the create ticket button (default: Create Ticket)") + .setRequired(false), + ) + .addChannelOption((opt) => + opt.setName("category") + .setDescription("Category where tickets from this panel will be created") + .addChannelTypes(ChannelType.GuildCategory) + .setRequired(false), + ) + .addChannelOption((opt) => + opt.setName("closed_category") + .setDescription("Category where closed tickets from this panel will go") + .addChannelTypes(ChannelType.GuildCategory) + .setRequired(false), + ) + .addRoleOption((opt) => + opt.setName("staff_role") + .setDescription("Staff role that can access tickets from this panel") + .setRequired(false), + ) + .addIntegerOption((opt) => + opt.setName("max_tickets_per_user") + .setDescription("Max open tickets per user for this panel (default: 3)") + .setMinValue(1) + .setMaxValue(10) + .setRequired(false), + ) + .addBooleanOption((opt) => + opt.setName("dm_on_close") + .setDescription("DM user when ticket closed (default: true)") + .setRequired(false), + ), + ) + + // panel list + .addSubcommand((sub) => + sub.setName("list") + .setDescription("List all ticket panels for this server"), + ) + + // panel delete + .addSubcommand((sub) => + sub.setName("delete") + .setDescription("Delete a ticket panel") + .addStringOption((opt) => + opt.setName("panel_id") + .setDescription("The panel ID to delete (get from /ticket panel list)") + .setRequired(true), + ), + ), ), + category: "ticket", async execute(interaction, config, client) { try { - const deferred = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); - if (!deferred) { - return; - } + if (!deferred) return; - if ( - !interaction.member.permissions.has( - PermissionFlagsBits.ManageChannels, - ) - ) { + if (!interaction.member.permissions.has(PermissionFlagsBits.ManageChannels)) { logger.warn('Ticket command permission denied', { userId: interaction.user.id, guildId: interaction.guildId, @@ -115,186 +170,133 @@ export default { } const subcommand = interaction.options.getSubcommand(); + const subcommandGroup = interaction.options.getSubcommandGroup(false); - if (subcommand === "dashboard") { - return ticketConfig.execute(interaction, config, client); - } - - if (subcommand === "setup") { - const existingConfig = await getGuildConfig(client, interaction.guildId); - if (existingConfig?.ticketPanelChannelId) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `This server already has a ticket system set up (panel in <#${existingConfig.ticketPanelChannelId}>).\n\nOnly one ticket system is supported per server. Use \`/ticket dashboard\` to edit or update the existing setup, or select **Delete System** from the dashboard to remove it and start fresh.` }); + // Handle panel subcommand group + if (subcommandGroup === 'panel') { + if (subcommand === 'add') return await handlePanelAdd(interaction, client); + if (subcommand === 'list') return await handlePanelList(interaction, client); + if (subcommand === 'delete') return await handlePanelDelete(interaction, client); + return; } - const panelChannel = - interaction.options.getChannel("panel_channel"); - const categoryChannel = interaction.options.getChannel("category"); - const closedCategoryChannel = interaction.options.getChannel("closed_category"); - const staffRole = interaction.options.getRole("staff_role"); -const panelMessage = interaction.options.getString("panel_message") || "Click the button below to create a support ticket."; - const buttonLabel = - interaction.options.getString("button_label") || -"Create Ticket"; - const maxTicketsPerUser = interaction.options.getInteger("max_tickets_per_user") || 3; -const dmOnClose = interaction.options.getBoolean("dm_on_close") !== false; + // Handle existing subcommands + if (subcommand === "dashboard") { + return ticketConfig.execute(interaction, config, client); + } - const setupEmbed = createEmbed({ - title: "Support Tickets", -description: panelMessage, - color: getColor('info') - }); + if (subcommand === "setup") { + const existingConfig = await getGuildConfig(client, interaction.guildId); + if (existingConfig?.ticketPanelChannelId) { + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `This server already has a ticket system set up (panel in <#${existingConfig.ticketPanelChannelId}>).\n\nOnly one ticket system is supported per server. Use \`/ticket dashboard\` to edit or update the existing setup, or select **Delete System** from the dashboard to remove it and start fresh.\n\n💡 To create additional panels, use \`/ticket panel add\`.` }); + } - const ticketButton = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId("create_ticket") -.setLabel(buttonLabel) - .setStyle(ButtonStyle.Primary) - .setEmoji("📩"), - ); + const panelChannel = interaction.options.getChannel("panel_channel"); + const categoryChannel = interaction.options.getChannel("category"); + const closedCategoryChannel = interaction.options.getChannel("closed_category"); + const staffRole = interaction.options.getRole("staff_role"); + const panelMessage = interaction.options.getString("panel_message") || "Click the button below to create a support ticket."; + const buttonLabel = interaction.options.getString("button_label") || "Create Ticket"; + const maxTicketsPerUser = interaction.options.getInteger("max_tickets_per_user") || 3; + const dmOnClose = interaction.options.getBoolean("dm_on_close") !== false; - try { - const sentPanel = await panelChannel.send({ - embeds: [setupEmbed], - components: [ticketButton], + const setupEmbed = createEmbed({ + title: "Support Tickets", + description: panelMessage, + color: getColor('info') }); - if (client.db && interaction.guildId) { - const currentConfig = existingConfig; - currentConfig.ticketCategoryId = categoryChannel ? categoryChannel.id : null; - currentConfig.ticketClosedCategoryId = closedCategoryChannel ? closedCategoryChannel.id : null; - currentConfig.ticketStaffRoleId = staffRole ? staffRole.id : null; - currentConfig.ticketPanelChannelId = panelChannel.id; - currentConfig.ticketPanelMessageId = sentPanel?.id || null; - currentConfig.ticketPanelMessage = panelMessage; - currentConfig.ticketButtonLabel = buttonLabel; - currentConfig.maxTicketsPerUser = maxTicketsPerUser; - currentConfig.dmOnClose = dmOnClose; + const ticketButton = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId("create_ticket") + .setLabel(buttonLabel) + .setStyle(ButtonStyle.Primary) + .setEmoji("📩"), + ); - const { getGuildConfigKey } = await import('../../utils/database.js'); - const configKey = getGuildConfigKey(interaction.guildId); - await client.db.set(configKey, currentConfig); - logger.info('Ticket configuration saved', { - guildId: interaction.guildId, - categoryId: categoryChannel?.id, - closedCategoryId: closedCategoryChannel?.id, - staffRoleId: staffRole?.id, - maxTickets: maxTicketsPerUser, - dmOnClose: dmOnClose - }); - } + try { + const sentPanel = await panelChannel.send({ + embeds: [setupEmbed], + components: [ticketButton], + }); - let successMessage = `The ticket creation panel has been sent to ${panelChannel}.`; - - if (categoryChannel) { - successMessage += `New tickets will be created in the **${categoryChannel.name}** category.`; - } else { - successMessage += 'New tickets will be created in a new "Tickets" category.'; - } - - if (closedCategoryChannel) { - successMessage += `Closed tickets will be moved to **${closedCategoryChannel.name}**.`; - } - - if (staffRole) { - successMessage += `**${staffRole.name}** role will have access to tickets.`; - } - - successMessage += `\n\n**Max Tickets Per User:** ${maxTicketsPerUser}\n**DM on Close:** ${dmOnClose ? 'Enabled' : 'Disabled'}`; + if (client.db && interaction.guildId) { + const currentConfig = existingConfig; + currentConfig.ticketCategoryId = categoryChannel ? categoryChannel.id : null; + currentConfig.ticketClosedCategoryId = closedCategoryChannel ? closedCategoryChannel.id : null; + currentConfig.ticketStaffRoleId = staffRole ? staffRole.id : null; + currentConfig.ticketPanelChannelId = panelChannel.id; + currentConfig.ticketPanelMessageId = sentPanel?.id || null; + currentConfig.ticketPanelMessage = panelMessage; + currentConfig.ticketButtonLabel = buttonLabel; + currentConfig.maxTicketsPerUser = maxTicketsPerUser; + currentConfig.dmOnClose = dmOnClose; - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - "Ticket Panel Set Up", - successMessage, - ), - ], - }); + const { getGuildConfigKey } = await import('../../utils/database.js'); + const configKey = getGuildConfigKey(interaction.guildId); + await client.db.set(configKey, currentConfig); + logger.info('Ticket configuration saved', { + guildId: interaction.guildId, + categoryId: categoryChannel?.id, + closedCategoryId: closedCategoryChannel?.id, + staffRoleId: staffRole?.id, + maxTickets: maxTicketsPerUser, + dmOnClose: dmOnClose + }); + } - logger.info('Ticket panel setup completed', { - userId: interaction.user.id, - userTag: interaction.user.tag, - guildId: interaction.guildId, - panelChannelId: panelChannel.id, - categoryId: categoryChannel?.id, - closedCategoryId: closedCategoryChannel?.id, - staffRoleId: staffRole?.id, - maxTickets: maxTicketsPerUser, - dmOnClose: dmOnClose, - commandName: 'ticket_setup' - }); + let successMessage = `The ticket creation panel has been sent to ${panelChannel}.`; + if (categoryChannel) { + successMessage += ` New tickets will be created in the **${categoryChannel.name}** category.`; + } else { + successMessage += ' New tickets will be created in a new "Tickets" category.'; + } + if (closedCategoryChannel) { + successMessage += ` Closed tickets will be moved to **${closedCategoryChannel.name}**.`; + } + if (staffRole) { + successMessage += ` **${staffRole.name}** role will have access to tickets.`; + } + successMessage += `\n\n**Max Tickets Per User:** ${maxTicketsPerUser}\n**DM on Close:** ${dmOnClose ? 'Enabled' : 'Disabled'}`; + successMessage += `\n\n💡 To create additional panels, use \`/ticket panel add\`.`; - const logEmbed = createEmbed({ - title: "Ticket System Setup (Configuration Log)", - description: `The ticket panel was set up in ${panelChannel} by ${interaction.user}.`, - color: getColor('warning') - }) - .addFields( - { - name: "Panel Channel", - value: panelChannel.toString(), - inline: true, - }, - { - name: "Ticket Category", - value: categoryChannel - ? categoryChannel.toString() - : "None specified.", - inline: true, - }, - { - name: "Closed Category", - value: closedCategoryChannel - ? closedCategoryChannel.toString() - : "None specified.", - inline: true, - }, - { - name: "Staff Role", - value: staffRole - ? staffRole.toString() - : "None specified.", - inline: true, - }, - { - name: "Max Tickets Per User", - value: maxTicketsPerUser.toString(), - inline: true, - }, - { - name: "DM on Close", - value: dmOnClose ? 'Enabled' : 'Disabled', - inline: true, - }, - { - name: "Moderator", - value: `${interaction.user.tag} (${interaction.user.id})`, - inline: false, - }, - ); + await InteractionHelper.safeEditReply(interaction, { + embeds: [successEmbed("Ticket Panel Set Up", successMessage)], + }); - } catch (error) { - logger.error('Ticket setup error', { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'ticket_setup' - }); - if (interaction.deferred || interaction.replied) { - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Could not send the ticket panel or save configuration. Check the bot\'s permissions (especially the ability to send messages in the target channel) and database connection.' }).catch(err => { - logger.error('Failed to send error reply', { - error: err.message, - guildId: interaction.guildId - }); + logger.info('Ticket panel setup completed', { + userId: interaction.user.id, + userTag: interaction.user.tag, + guildId: interaction.guildId, + panelChannelId: panelChannel.id, + categoryId: categoryChannel?.id, + closedCategoryId: closedCategoryChannel?.id, + staffRoleId: staffRole?.id, + maxTickets: maxTicketsPerUser, + dmOnClose: dmOnClose, + commandName: 'ticket_setup' }); - } else { - await handleInteractionError(interaction, error, { - commandName: 'ticket_setup', - source: 'ticket_setup_command' + + } catch (error) { + logger.error('Ticket setup error', { + error: error.message, + stack: error.stack, + userId: interaction.user.id, + guildId: interaction.guildId, + commandName: 'ticket_setup' }); + if (interaction.deferred || interaction.replied) { + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Could not send the ticket panel or save configuration. Check the bot\'s permissions and database connection.' }).catch(err => { + logger.error('Failed to send error reply', { error: err.message, guildId: interaction.guildId }); + }); + } else { + await handleInteractionError(interaction, error, { + commandName: 'ticket_setup', + source: 'ticket_setup_command' + }); + } } } - } } catch (error) { logger.error('Error executing ticket command', { error: error.message, @@ -309,4 +311,4 @@ description: panelMessage, }); } } -}; \ No newline at end of file +}; diff --git a/src/events/interactionCreate.js b/src/events/interactionCreate.js index 06aa1c180..b62b9bb4d 100644 --- a/src/events/interactionCreate.js +++ b/src/events/interactionCreate.js @@ -285,6 +285,23 @@ export default { return; } + if (interaction.customId.startsWith('create_ticket_') && !interaction.customId.startsWith('create_ticket_modal')) { + // Multi-panel ticket button (create_ticket_PANELID) + const button = client.buttons.get('create_ticket_panel'); + if (button) { + try { + await button.execute(interaction, client, []); + } catch (error) { + await handleInteractionError(interaction, error, withTraceContext({ + type: 'button', + customId: interaction.customId, + handler: 'ticket_panel' + }, interactionTraceContext)); + } + } + return; + } + if (interaction.customId.startsWith('loa_')) { const parts = interaction.customId.split('_'); const buttonType = `${parts[0]}_${parts[1]}`; // e.g. "loa_approve" @@ -355,6 +372,23 @@ export default { }, interactionTraceContext)); } } else if (interaction.isModalSubmit()) { + if (interaction.customId.startsWith('create_ticket_modal_')) { + // Multi-panel ticket modal submission + const modal = client.buttons.get('create_ticket_modal_panel'); + if (modal) { + try { + await modal.execute(interaction, client, []); + } catch (error) { + await handleInteractionError(interaction, error, withTraceContext({ + type: 'modal', + customId: interaction.customId, + handler: 'ticket_panel_modal' + }, interactionTraceContext)); + } + } + return; + } + if (interaction.customId.startsWith('app_modal_')) { try { await handleApplicationModal(interaction); @@ -449,4 +483,4 @@ export default { } }); } -}; +}; \ No newline at end of file diff --git a/src/handlers/ticketButtons.js b/src/handlers/ticketButtons.js index cedc7fc3d..db9058707 100644 --- a/src/handlers/ticketButtons.js +++ b/src/handlers/ticketButtons.js @@ -8,6 +8,7 @@ import { InteractionHelper } from '../utils/interactionHelper.js'; import { checkRateLimit } from '../utils/rateLimiter.js'; import { replyUserError, ErrorTypes } from '../utils/errorHandler.js'; import { getTicketPermissionContext } from '../utils/ticketPermissions.js'; +import { getPanels } from '../commands/Ticket/modules/ticket_panels.js'; function escapeHtml(text) { if (!text) return ''; @@ -609,9 +610,118 @@ const deleteTicketHandler = { } }; + +// Handler for multi-panel buttons (create_ticket_PANELID) +const createPanelTicketHandler = { + name: 'create_ticket_panel', + async execute(interaction, client) { + try { + if (!(await ensureGuildContext(interaction))) return; + + const rateLimitKey = `${interaction.user.id}:create_ticket`; + const allowed = await checkRateLimit(rateLimitKey, 3, 60000); + if (!allowed) { + await replyUserError(interaction, { type: ErrorTypes.RATE_LIMIT, message: 'You are creating tickets too quickly. Please wait a minute and try again.' }); + return; + } + + // Extract panel ID from customId (create_ticket_PANELID) + const panelId = interaction.customId.replace('create_ticket_', ''); + + // Find panel config + const panels = await getPanels(interaction.guildId); + const panel = panels.find(p => p.panelId === panelId); + + if (!panel) { + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'This ticket panel no longer exists. Please contact staff.' }); + return; + } + + const maxTicketsPerUser = panel.maxTicketsPerUser || 3; + const { getUserTicketCount } = await import('../services/ticket.js'); + const currentTicketCount = await getUserTicketCount(interaction.guildId, interaction.user.id); + + if (currentTicketCount >= maxTicketsPerUser) { + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `You have reached the maximum number of open tickets (${maxTicketsPerUser}).\n\nPlease close your existing tickets before creating a new one.\n\n**Current Tickets:** ${currentTicketCount}/${maxTicketsPerUser}` }); + } + + const modal = new ModalBuilder() + .setCustomId(`create_ticket_modal_${panelId}`) + .setTitle(`Create a ${panel.panelTitle || 'Ticket'}`); + + const reasonInput = new TextInputBuilder() + .setCustomId('reason') + .setLabel('Why are you creating this ticket?') + .setStyle(TextInputStyle.Paragraph) + .setPlaceholder('Describe your issue...') + .setRequired(true) + .setMaxLength(1000); + + modal.addComponents(new ActionRowBuilder().addComponents(reasonInput)); + await interaction.showModal(modal); + } catch (error) { + logger.error('Error creating panel ticket modal:', error); + if (!interaction.replied && !interaction.deferred) { + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Could not open ticket creation form.' }); + } + } + } +}; + +const createPanelTicketModalHandler = { + name: 'create_ticket_modal_panel', + async execute(interaction, client) { + try { + if (!(await ensureGuildContext(interaction))) return; + + const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); + if (!deferSuccess) return; + + // Extract panel ID from customId (create_ticket_modal_PANELID) + const panelId = interaction.customId.replace('create_ticket_modal_', ''); + + // Find panel config + const panels = await getPanels(interaction.guildId); + const panel = panels.find(p => p.panelId === panelId); + + if (!panel) { + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'This ticket panel no longer exists.' }); + return; + } + + const reason = interaction.fields.getTextInputValue('reason'); + const categoryId = panel.categoryId || null; + + const result = await createTicket( + interaction.guild, + interaction.member, + categoryId, + reason, + { staffRoleId: panel.staffRoleId, panelId, panelTitle: panel.panelTitle } + ); + + if (result.success) { + await interaction.editReply({ + embeds: [successEmbed( + 'Ticket Created', + `Your ticket has been created in ${result.channel}!` + )] + }); + } else { + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: result.error || 'Failed to create ticket.' }); + } + } catch (error) { + logger.error('Error creating panel ticket:', error); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while creating your ticket.' }); + } + } +}; + export default createTicketHandler; export { - createTicketModalHandler, + createTicketModalHandler, + createPanelTicketHandler, + createPanelTicketModalHandler, closeTicketModalHandler, closeTicketHandler, claimTicketHandler, diff --git a/src/interactions/buttons/ticket.js b/src/interactions/buttons/ticket.js index bcc2521f7..1049944aa 100644 --- a/src/interactions/buttons/ticket.js +++ b/src/interactions/buttons/ticket.js @@ -6,6 +6,8 @@ import createTicketHandler, { unclaimTicketHandler, reopenTicketHandler, deleteTicketHandler, + createPanelTicketHandler, + createPanelTicketModalHandler, } from '../../handlers/ticketButtons.js'; export default [ @@ -17,4 +19,6 @@ export default [ unclaimTicketHandler, reopenTicketHandler, deleteTicketHandler, -]; \ No newline at end of file + createPanelTicketHandler, + createPanelTicketModalHandler, +]; From fe55fd860fa525d749807504d6864a00f6f137e9 Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:31:14 -0600 Subject: [PATCH 035/115] Update interactionCreate.js --- src/events/interactionCreate.js | 114 +++++++++++++++++++++++++------- 1 file changed, 91 insertions(+), 23 deletions(-) diff --git a/src/events/interactionCreate.js b/src/events/interactionCreate.js index b62b9bb4d..78e9b0fa2 100644 --- a/src/events/interactionCreate.js +++ b/src/events/interactionCreate.js @@ -12,6 +12,11 @@ import { isCommandEnabled } from '../services/commandAccessService.js'; import { resolveSlashAccessKey } from '../utils/messageAdapter.js'; import { isCollectorManagedComponent } from '../utils/collectorComponents.js'; import { ResponseCoordinator } from '../utils/responseCoordinator.js'; +import { getPanels } from '../commands/Ticket/modules/ticket_panels.js'; +import { createTicket } from '../services/ticket.js'; +import { successEmbed } from '../utils/embeds.js'; +import { replyUserError } from '../utils/errorHandler.js'; +import { checkRateLimit } from '../utils/rateLimiter.js'; function withTraceContext(context = {}, traceContext = {}) { return { @@ -286,18 +291,54 @@ export default { } if (interaction.customId.startsWith('create_ticket_') && !interaction.customId.startsWith('create_ticket_modal')) { - // Multi-panel ticket button (create_ticket_PANELID) - const button = client.buttons.get('create_ticket_panel'); - if (button) { - try { - await button.execute(interaction, client, []); - } catch (error) { - await handleInteractionError(interaction, error, withTraceContext({ - type: 'button', - customId: interaction.customId, - handler: 'ticket_panel' - }, interactionTraceContext)); + // Multi-panel ticket button — handle inline + try { + const panelId = interaction.customId.replace('create_ticket_', ''); + const panels = await getPanels(interaction.guildId); + const panel = panels.find(p => p.panelId === panelId); + + if (!panel) { + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'This ticket panel no longer exists. Please contact staff.' }); + return; } + + const { checkRateLimit: rl } = await import('../utils/rateLimiter.js'); + const allowed = await rl(`${interaction.user.id}:create_ticket`, 3, 60000); + if (!allowed) { + await replyUserError(interaction, { type: ErrorTypes.RATE_LIMIT, message: 'You are creating tickets too quickly. Please wait a minute and try again.' }); + return; + } + + const { getUserTicketCount } = await import('../services/ticket.js'); + const currentTicketCount = await getUserTicketCount(interaction.guildId, interaction.user.id); + const maxTicketsPerUser = panel.maxTicketsPerUser || 3; + + if (currentTicketCount >= maxTicketsPerUser) { + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `You have reached the maximum number of open tickets (${maxTicketsPerUser}).\n\nPlease close your existing tickets before creating a new one.\n\n**Current Tickets:** ${currentTicketCount}/${maxTicketsPerUser}` }); + return; + } + + const { ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder: ARB } = await import('discord.js'); + const modal = new ModalBuilder() + .setCustomId(`create_ticket_modal_${panelId}`) + .setTitle(`Create a ${panel.panelTitle || 'Ticket'}`); + + const reasonInput = new TextInputBuilder() + .setCustomId('reason') + .setLabel('Why are you creating this ticket?') + .setStyle(TextInputStyle.Paragraph) + .setPlaceholder('Describe your issue...') + .setRequired(true) + .setMaxLength(1000); + + modal.addComponents(new ARB().addComponents(reasonInput)); + await interaction.showModal(modal); + } catch (error) { + await handleInteractionError(interaction, error, withTraceContext({ + type: 'button', + customId: interaction.customId, + handler: 'ticket_panel' + }, interactionTraceContext)); } return; } @@ -373,18 +414,45 @@ export default { } } else if (interaction.isModalSubmit()) { if (interaction.customId.startsWith('create_ticket_modal_')) { - // Multi-panel ticket modal submission - const modal = client.buttons.get('create_ticket_modal_panel'); - if (modal) { - try { - await modal.execute(interaction, client, []); - } catch (error) { - await handleInteractionError(interaction, error, withTraceContext({ - type: 'modal', - customId: interaction.customId, - handler: 'ticket_panel_modal' - }, interactionTraceContext)); + // Multi-panel ticket modal submission — handle inline + try { + const { MessageFlags: MF } = await import('discord.js'); + const { InteractionHelper: IH } = await import('../utils/interactionHelper.js'); + const deferSuccess = await IH.safeDefer(interaction, { flags: MF.Ephemeral }); + if (!deferSuccess) return; + + const panelId = interaction.customId.replace('create_ticket_modal_', ''); + const panels = await getPanels(interaction.guildId); + const panel = panels.find(p => p.panelId === panelId); + + if (!panel) { + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'This ticket panel no longer exists.' }); + return; + } + + const reason = interaction.fields.getTextInputValue('reason'); + const { createTicket: ct } = await import('../services/ticket.js'); + const result = await ct( + interaction.guild, + interaction.member, + panel.categoryId || null, + reason, + { staffRoleId: panel.staffRoleId, panelId, panelTitle: panel.panelTitle } + ); + + if (result.success) { + await interaction.editReply({ + embeds: [(await import('../utils/embeds.js')).successEmbed('Ticket Created', `Your ticket has been created in ${result.channel}!`)] + }); + } else { + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: result.error || 'Failed to create ticket.' }); } + } catch (error) { + await handleInteractionError(interaction, error, withTraceContext({ + type: 'modal', + customId: interaction.customId, + handler: 'ticket_panel_modal' + }, interactionTraceContext)); } return; } @@ -483,4 +551,4 @@ export default { } }); } -}; \ No newline at end of file +}; From e9bd6a23fb0947ebaf1a72d8bab3ed77dc5f27b8 Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:43:26 -0600 Subject: [PATCH 036/115] Update ticket.js --- src/services/ticket.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/services/ticket.js b/src/services/ticket.js index 07a9cc8fa..9b35b8095 100644 --- a/src/services/ticket.js +++ b/src/services/ticket.js @@ -125,8 +125,8 @@ export async function createTicket(guild, member, categoryId, reason = 'No reaso PermissionFlagsBits.ReadMessageHistory, ], }, - ...(config.ticketStaffRoleId ? [{ - id: config.ticketStaffRoleId, + ...((panelStaffRoleId || config.ticketStaffRoleId) ? [{ + id: panelStaffRoleId || config.ticketStaffRoleId, allow: [ PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, @@ -180,7 +180,8 @@ export async function createTicket(guild, member, categoryId, reason = 'No reaso ); } - const staffMention = config.ticketStaffRoleId ? ` <@&${config.ticketStaffRoleId}>` : ''; + const effectiveStaffRoleId = panelStaffRoleId || config.ticketStaffRoleId; + const staffMention = effectiveStaffRoleId ? ` <@&${effectiveStaffRoleId}>` : ''; const messageContent = `${member.toString()}${staffMention}`; const ticketMessage = await channel.send({ From cb303b8f522c4f41058af9061f8d6168313c3cad Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:48:11 -0600 Subject: [PATCH 037/115] Update ticketButtons.js --- src/handlers/ticketButtons.js | 92 ++++++++++++++++++++++++++++++----- 1 file changed, 80 insertions(+), 12 deletions(-) diff --git a/src/handlers/ticketButtons.js b/src/handlers/ticketButtons.js index db9058707..f8e02edc5 100644 --- a/src/handlers/ticketButtons.js +++ b/src/handlers/ticketButtons.js @@ -138,7 +138,12 @@ const createTicketHandler = { await interaction.showModal(modal); } catch (error) { - logger.error('Error creating ticket modal:', error); + logger.error('Error creating ticket modal:', { + error: error.message, + stack: error.stack, + userId: interaction.user.id, + guildId: interaction.guildId + }); if (!interaction.replied && !interaction.deferred) { await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Could not open ticket creation form.' }); } @@ -177,7 +182,12 @@ const createTicketModalHandler = { await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: result.error || 'Failed to create ticket.' }); } } catch (error) { - logger.error('Error creating ticket:', error); + logger.error('Error creating ticket:', { + error: error.message, + stack: error.stack, + userId: interaction.user.id, + guildId: interaction.guildId + }); await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while creating your ticket.' }); } } @@ -219,7 +229,13 @@ const closeTicketHandler = { await interaction.showModal(modal); } catch (error) { - logger.error('Error closing ticket:', error); + logger.error('Error closing ticket:', { + error: error.message, + stack: error.stack, + userId: interaction.user.id, + guildId: interaction.guildId, + channelId: interaction.channelId + }); if (!interaction.replied && !interaction.deferred) { await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Could not open ticket close form.' }); @@ -264,7 +280,13 @@ const closeTicketModalHandler = { await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: result.error || 'Failed to close ticket.' }); } } catch (error) { - logger.error('Error submitting close ticket modal:', error); + logger.error('Error submitting close ticket modal:', { + error: error.message, + stack: error.stack, + userId: interaction.user.id, + guildId: interaction.guildId, + channelId: interaction.channelId + }); if (!interaction.replied && !interaction.deferred) { await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while closing the ticket.' }); } else if (interaction.deferred) { @@ -307,7 +329,13 @@ const claimTicketHandler = { await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: result.error || 'Failed to claim ticket.' }); } } catch (error) { - logger.error('Error claiming ticket:', error); + logger.error('Error claiming ticket:', { + error: error.message, + stack: error.stack, + userId: interaction.user.id, + guildId: interaction.guildId, + channelId: interaction.channelId + }); if (!interaction.replied && !interaction.deferred) { await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while claiming the ticket.' }); } else if (interaction.deferred) { @@ -356,7 +384,13 @@ const priorityTicketHandler = { await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: result.error || 'Failed to update priority.' }); } } catch (error) { - logger.error('Error updating ticket priority:', error); + logger.error('Error updating ticket priority:', { + error: error.message, + stack: error.stack, + userId: interaction.user.id, + guildId: interaction.guildId, + channelId: interaction.channelId + }); if (!interaction.replied && !interaction.deferred) { await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while updating the priority.' }); } else if (interaction.deferred) { @@ -463,7 +497,13 @@ const pinTicketHandler = { }); } catch (error) { - logger.error('Error pinning/unpinning ticket:', error); + logger.error('Error pinning/unpinning ticket:', { + error: error.message, + stack: error.stack, + userId: interaction.user.id, + guildId: interaction.guildId, + channelId: interaction.channelId + }); if (!interaction.replied && !interaction.deferred) { await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Failed to pin/unpin the ticket.' }); } else if (interaction.deferred) { @@ -507,7 +547,13 @@ const unclaimTicketHandler = { await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: result.error || 'Failed to unclaim ticket.' }); } } catch (error) { - logger.error('Error unclaiming ticket:', error); + logger.error('Error unclaiming ticket:', { + error: error.message, + stack: error.stack, + userId: interaction.user.id, + guildId: interaction.guildId, + channelId: interaction.channelId + }); if (!interaction.replied && !interaction.deferred) { await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while unclaiming the ticket.' }); } else if (interaction.deferred) { @@ -556,7 +602,13 @@ const reopenTicketHandler = { await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: result.error || 'Failed to reopen ticket.' }); } } catch (error) { - logger.error('Error reopening ticket:', error); + logger.error('Error reopening ticket:', { + error: error.message, + stack: error.stack, + userId: interaction.user.id, + guildId: interaction.guildId, + channelId: interaction.channelId + }); if (!interaction.replied && !interaction.deferred) { await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while reopening the ticket.' }); } else if (interaction.deferred) { @@ -600,7 +652,13 @@ const deleteTicketHandler = { await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: result.error || 'Failed to delete ticket.' }); } } catch (error) { - logger.error('Error deleting ticket:', error); + logger.error('Error deleting ticket:', { + error: error.message, + stack: error.stack, + userId: interaction.user.id, + guildId: interaction.guildId, + channelId: interaction.channelId + }); if (!interaction.replied && !interaction.deferred) { await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while deleting the ticket.' }); } else if (interaction.deferred) { @@ -660,7 +718,12 @@ const createPanelTicketHandler = { modal.addComponents(new ActionRowBuilder().addComponents(reasonInput)); await interaction.showModal(modal); } catch (error) { - logger.error('Error creating panel ticket modal:', error); + logger.error('Error creating panel ticket modal:', { + error: error.message, + stack: error.stack, + userId: interaction.user.id, + guildId: interaction.guildId + }); if (!interaction.replied && !interaction.deferred) { await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Could not open ticket creation form.' }); } @@ -711,7 +774,12 @@ const createPanelTicketModalHandler = { await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: result.error || 'Failed to create ticket.' }); } } catch (error) { - logger.error('Error creating panel ticket:', error); + logger.error('Error creating panel ticket:', { + error: error.message, + stack: error.stack, + userId: interaction.user.id, + guildId: interaction.guildId + }); await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while creating your ticket.' }); } } From 133f20143551e76d4a59fb5b8c047f9df578f1bf Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:50:46 -0600 Subject: [PATCH 038/115] Update ticket.js --- src/services/ticket.js | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/services/ticket.js b/src/services/ticket.js index 9b35b8095..588aeabab 100644 --- a/src/services/ticket.js +++ b/src/services/ticket.js @@ -61,11 +61,16 @@ export async function getUserTicketCount(guildId, userId) { } } -export async function createTicket(guild, member, categoryId, reason = 'No reason provided', priority = 'none') { +export async function createTicket(guild, member, categoryId, reason = 'No reason provided', options = {}) { try { const config = await getGuildConfig(guild.client, guild.id); const ticketConfig = config.tickets || {}; + // Handle backwards compatibility: 5th param can be a string (priority) or object (options) + const actualOptions = typeof options === 'string' ? { priority: options } : (options || {}); + const priority = actualOptions.priority || 'none'; + const staffRoleIdFromOptions = actualOptions.staffRoleId || null; + const maxTicketsPerUser = config.maxTicketsPerUser ?? 3; const currentTicketCount = await getUserTicketCount(guild.id, member.id); @@ -125,8 +130,8 @@ export async function createTicket(guild, member, categoryId, reason = 'No reaso PermissionFlagsBits.ReadMessageHistory, ], }, - ...((panelStaffRoleId || config.ticketStaffRoleId) ? [{ - id: panelStaffRoleId || config.ticketStaffRoleId, + ...((staffRoleIdFromOptions || config.ticketStaffRoleId) ? [{ + id: staffRoleIdFromOptions || config.ticketStaffRoleId, allow: [ PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, @@ -180,7 +185,7 @@ export async function createTicket(guild, member, categoryId, reason = 'No reaso ); } - const effectiveStaffRoleId = panelStaffRoleId || config.ticketStaffRoleId; + const effectiveStaffRoleId = staffRoleIdFromOptions || config.ticketStaffRoleId; const staffMention = effectiveStaffRoleId ? ` <@&${effectiveStaffRoleId}>` : ''; const messageContent = `${member.toString()}${staffMention}`; From 80b7f3c6941219b0571ca67d669998aad921eaf4 Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Sat, 20 Jun 2026 21:03:06 -0600 Subject: [PATCH 039/115] Update ticket_panels.js --- src/commands/Ticket/modules/ticket_panels.js | 291 +++++++++++-------- 1 file changed, 168 insertions(+), 123 deletions(-) diff --git a/src/commands/Ticket/modules/ticket_panels.js b/src/commands/Ticket/modules/ticket_panels.js index 5d84e5c9b..120d2b83f 100644 --- a/src/commands/Ticket/modules/ticket_panels.js +++ b/src/commands/Ticket/modules/ticket_panels.js @@ -34,143 +34,188 @@ export function generatePanelId() { } export async function handlePanelAdd(interaction, client) { - const panelChannel = interaction.options.getChannel('panel_channel'); - const panelMessage = interaction.options.getString('panel_message'); - const buttonLabel = interaction.options.getString('button_label') || 'Create Ticket'; - const panelTitle = interaction.options.getString('panel_title') || 'Support Tickets'; - const category = interaction.options.getChannel('category'); - const closedCategory = interaction.options.getChannel('closed_category'); - const staffRole = interaction.options.getRole('staff_role'); - const maxTickets = interaction.options.getInteger('max_tickets_per_user') || 3; - const dmOnClose = interaction.options.getBoolean('dm_on_close') !== false; - - const panelId = generatePanelId(); - - // Build and send the panel embed - const embed = new EmbedBuilder() - .setTitle(panelTitle) - .setDescription(panelMessage) - .setColor(getColor('info')); - - const button = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(`create_ticket_${panelId}`) - .setLabel(buttonLabel) - .setStyle(ButtonStyle.Primary) - .setEmoji('📩'), - ); - - const sentPanel = await panelChannel.send({ embeds: [embed], components: [button] }); - - // Save panel config - const panels = await getPanels(interaction.guildId); - panels.push({ - panelId, - panelTitle, - panelMessage, - buttonLabel, - channelId: panelChannel.id, - messageId: sentPanel.id, - categoryId: category?.id || null, - closedCategoryId: closedCategory?.id || null, - staffRoleId: staffRole?.id || null, - maxTicketsPerUser: maxTickets, - dmOnClose, - createdBy: interaction.user.id, - createdAt: new Date().toISOString(), - }); - await savePanels(interaction.guildId, panels); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - '✅ Panel Created', - `Panel \`${panelId}\` has been posted in <#${panelChannel.id}>.\n\n` + - `**Title:** ${panelTitle}\n` + - `**Button:** ${buttonLabel}\n` + - `**Category:** ${category ? category.name : 'Not set'}\n` + - `**Staff Role:** ${staffRole ? staffRole.name : 'Not set'}\n` + - `**Max Tickets/User:** ${maxTickets}\n` + - `**DM on Close:** ${dmOnClose ? 'Enabled' : 'Disabled'}` - ), - ], - }); - - logger.info('Ticket panel created', { - panelId, - guildId: interaction.guildId, - channelId: panelChannel.id, - userId: interaction.user.id, - }); -} - -export async function handlePanelList(interaction, client) { - const panels = await getPanels(interaction.guildId); + try { + const panelChannel = interaction.options.getChannel('panel_channel'); + const panelMessage = interaction.options.getString('panel_message'); + const buttonLabel = interaction.options.getString('button_label') || 'Create Ticket'; + const panelTitle = interaction.options.getString('panel_title') || 'Support Tickets'; + const category = interaction.options.getChannel('category'); + const closedCategory = interaction.options.getChannel('closed_category'); + const staffRole = interaction.options.getRole('staff_role'); + const maxTickets = interaction.options.getInteger('max_tickets_per_user') || 3; + const dmOnClose = interaction.options.getBoolean('dm_on_close') !== false; + + const panelId = generatePanelId(); + + // Build and send the panel embed + const embed = new EmbedBuilder() + .setTitle(panelTitle) + .setDescription(panelMessage) + .setColor(getColor('info')); + + const button = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`create_ticket_${panelId}`) + .setLabel(buttonLabel) + .setStyle(ButtonStyle.Primary) + .setEmoji('📩'), + ); + + const sentPanel = await panelChannel.send({ embeds: [embed], components: [button] }); + + // Save panel config + const panels = await getPanels(interaction.guildId); + panels.push({ + panelId, + panelTitle, + panelMessage, + buttonLabel, + channelId: panelChannel.id, + messageId: sentPanel.id, + categoryId: category?.id || null, + closedCategoryId: closedCategory?.id || null, + staffRoleId: staffRole?.id || null, + maxTicketsPerUser: maxTickets, + dmOnClose, + createdBy: interaction.user.id, + createdAt: new Date().toISOString(), + }); + await savePanels(interaction.guildId, panels); - if (panels.length === 0) { - return InteractionHelper.safeEditReply(interaction, { + await InteractionHelper.safeEditReply(interaction, { embeds: [ - new EmbedBuilder() - .setColor(getColor('info')) - .setTitle('🎫 Ticket Panels') - .setDescription('No panels have been created yet.\nUse `/ticket panel add` to create one.'), + successEmbed( + '✅ Panel Created', + `Panel \`${panelId}\` has been posted in <#${panelChannel.id}>.\n\n` + + `**Title:** ${panelTitle}\n` + + `**Button:** ${buttonLabel}\n` + + `**Category:** ${category ? category.name : 'Not set'}\n` + + `**Staff Role:** ${staffRole ? staffRole.name : 'Not set'}\n` + + `**Max Tickets/User:** ${maxTickets}\n` + + `**DM on Close:** ${dmOnClose ? 'Enabled' : 'Disabled'}` + ), ], }); - } - const embed = new EmbedBuilder() - .setColor(getColor('info')) - .setTitle('🎫 Ticket Panels') - .setDescription(`**${panels.length}** panel(s) configured for this server.`) - .setTimestamp(); - - for (const panel of panels) { - embed.addFields({ - name: `${panel.panelTitle} — \`${panel.panelId}\``, - value: [ - `**Channel:** <#${panel.channelId}>`, - `**Button:** ${panel.buttonLabel}`, - `**Category:** ${panel.categoryId ? `<#${panel.categoryId}>` : 'Not set'}`, - `**Staff Role:** ${panel.staffRoleId ? `<@&${panel.staffRoleId}>` : 'Not set'}`, - `**Max Tickets:** ${panel.maxTicketsPerUser}`, - ].join('\n'), - inline: false, + logger.info('Ticket panel created', { + panelId, + guildId: interaction.guildId, + channelId: panelChannel.id, + userId: interaction.user.id, + }); + } catch (error) { + logger.error('Error creating ticket panel:', { + error: error.message, + stack: error.stack, + guildId: interaction.guildId, + userId: interaction.user.id, + }); + await replyUserError(interaction, { + type: ErrorTypes.UNKNOWN, + message: `Failed to create panel: ${error.message}` }); } - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); } -export async function handlePanelDelete(interaction, client) { - const panelId = interaction.options.getString('panel_id'); - const panels = await getPanels(interaction.guildId); - const index = panels.findIndex(p => p.panelId === panelId); - - if (index === -1) { - return replyUserError(interaction, { - type: ErrorTypes.UNKNOWN, - message: `No panel found with ID \`${panelId}\`. Use \`/ticket panel list\` to see all panels.`, +export async function handlePanelList(interaction, client) { + try { + const panels = await getPanels(interaction.guildId); + + if (panels.length === 0) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [ + new EmbedBuilder() + .setColor(getColor('info')) + .setTitle('🎫 Ticket Panels') + .setDescription('No panels have been created yet.\nUse `/ticket panel add` to create one.'), + ], + }); + } + + const embed = new EmbedBuilder() + .setColor(getColor('info')) + .setTitle('🎫 Ticket Panels') + .setDescription(`**${panels.length}** panel(s) configured for this server.`) + .setTimestamp(); + + for (const panel of panels) { + embed.addFields({ + name: `${panel.panelTitle} — \`${panel.panelId}\``, + value: [ + `**Channel:** <#${panel.channelId}>`, + `**Button:** ${panel.buttonLabel}`, + `**Category:** ${panel.categoryId ? `<#${panel.categoryId}>` : 'Not set'}`, + `**Staff Role:** ${panel.staffRoleId ? `<@&${panel.staffRoleId}>` : 'Not set'}`, + `**Max Tickets:** ${panel.maxTicketsPerUser}`, + ].join('\n'), + inline: false, + }); + } + + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); + } catch (error) { + logger.error('Error listing ticket panels:', { + error: error.message, + stack: error.stack, + guildId: interaction.guildId, + userId: interaction.user.id, + }); + await replyUserError(interaction, { + type: ErrorTypes.UNKNOWN, + message: `Failed to list panels: ${error.message}` }); } +} - const panel = panels[index]; - - // Try to delete the panel message +export async function handlePanelDelete(interaction, client) { try { - const channel = interaction.guild.channels.cache.get(panel.channelId) - || await interaction.guild.channels.fetch(panel.channelId).catch(() => null); - if (channel && panel.messageId) { - const msg = await channel.messages.fetch(panel.messageId).catch(() => null); - if (msg) await msg.delete().catch(() => {}); + const panelId = interaction.options.getString('panel_id'); + const panels = await getPanels(interaction.guildId); + const index = panels.findIndex(p => p.panelId === panelId); + + if (index === -1) { + return replyUserError(interaction, { + type: ErrorTypes.UNKNOWN, + message: `No panel found with ID \`${panelId}\`. Use \`/ticket panel list\` to see all panels.`, + }); } - } catch (err) { - logger.warn(`Could not delete panel message for ${panelId}:`, err.message); - } - panels.splice(index, 1); - await savePanels(interaction.guildId, panels); + const panel = panels[index]; + + // Try to delete the panel message + try { + const channel = interaction.guild.channels.cache.get(panel.channelId) + || await interaction.guild.channels.fetch(panel.channelId).catch(() => null); + if (channel && panel.messageId) { + const msg = await channel.messages.fetch(panel.messageId).catch(() => null); + if (msg) await msg.delete().catch(() => {}); + } + } catch (err) { + logger.warn(`Could not delete panel message for ${panelId}:`, err.message); + } + + panels.splice(index, 1); + await savePanels(interaction.guildId, panels); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [successEmbed('✅ Panel Deleted', `Panel \`${panelId}\` (${panel.panelTitle}) has been removed.`)], + }); - await InteractionHelper.safeEditReply(interaction, { - embeds: [successEmbed('✅ Panel Deleted', `Panel \`${panelId}\` (${panel.panelTitle}) has been removed.`)], - }); + logger.info('Ticket panel deleted', { + panelId, + guildId: interaction.guildId, + userId: interaction.user.id, + }); + } catch (error) { + logger.error('Error deleting ticket panel:', { + error: error.message, + stack: error.stack, + guildId: interaction.guildId, + userId: interaction.user.id, + }); + await replyUserError(interaction, { + type: ErrorTypes.UNKNOWN, + message: `Failed to delete panel: ${error.message}` + }); + } } From dac84be9a1b60ac60d2dae577217f0e7527f2cda Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Sat, 20 Jun 2026 21:05:16 -0600 Subject: [PATCH 040/115] . --- src/events/interactionCreate.js | 3 ++- src/handlers/ticketButtons.js | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/events/interactionCreate.js b/src/events/interactionCreate.js index 78e9b0fa2..5d676af34 100644 --- a/src/events/interactionCreate.js +++ b/src/events/interactionCreate.js @@ -319,9 +319,10 @@ export default { } const { ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder: ARB } = await import('discord.js'); + const truncatedPanelTitle = (panel.panelTitle || 'Ticket').substring(0, 36); // Max 45 chars total, "Create a " = 9 chars const modal = new ModalBuilder() .setCustomId(`create_ticket_modal_${panelId}`) - .setTitle(`Create a ${panel.panelTitle || 'Ticket'}`); + .setTitle(`Create a ${truncatedPanelTitle}`); const reasonInput = new TextInputBuilder() .setCustomId('reason') diff --git a/src/handlers/ticketButtons.js b/src/handlers/ticketButtons.js index f8e02edc5..8a394f455 100644 --- a/src/handlers/ticketButtons.js +++ b/src/handlers/ticketButtons.js @@ -703,9 +703,10 @@ const createPanelTicketHandler = { return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `You have reached the maximum number of open tickets (${maxTicketsPerUser}).\n\nPlease close your existing tickets before creating a new one.\n\n**Current Tickets:** ${currentTicketCount}/${maxTicketsPerUser}` }); } + const truncatedPanelTitle = (panel.panelTitle || 'Ticket').substring(0, 36); // Max 45 chars total, "Create a " = 9 chars const modal = new ModalBuilder() .setCustomId(`create_ticket_modal_${panelId}`) - .setTitle(`Create a ${panel.panelTitle || 'Ticket'}`); + .setTitle(`Create a ${truncatedPanelTitle}`); const reasonInput = new TextInputBuilder() .setCustomId('reason') From 2461375c20c7d2dd8bcc98c482605a56b7381603 Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Sat, 20 Jun 2026 21:19:47 -0600 Subject: [PATCH 041/115] Update ticket.js --- src/services/ticket.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/services/ticket.js b/src/services/ticket.js index 588aeabab..35ba6b2d4 100644 --- a/src/services/ticket.js +++ b/src/services/ticket.js @@ -156,10 +156,13 @@ export async function createTicket(guild, member, categoryId, reason = 'No reaso await saveTicketData(guild.id, channel.id, ticketData); const priorityInfo = PRIORITY_MAP[priority] || PRIORITY_MAP.none; + const embedTitle = actualOptions.embedTitle || `Ticket #${ticketNumber} - ${usernameOrTag(member)}`; + const embedDescription = actualOptions.embedDescription || + `${member.toString()}, thanks for creating a ticket!\n\n**Reason:** ${reason}\n**Priority:** ${priorityInfo.emoji} ${priorityInfo.label}`; const embed = createEmbed({ - title: `Ticket #${ticketNumber}`, - description: `${member.toString()}, thanks for creating a ticket!\n\n**Reason:** ${reason}\n**Priority:** ${priorityInfo.emoji} ${priorityInfo.label}`, + title: embedTitle, + description: embedDescription, color: priorityInfo.color, fields: [ { name: 'Status', value: '🟢 Open', inline: true }, From 0aa80b74036a9ac57f914450085adaced43b7d5d Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Sat, 20 Jun 2026 21:20:32 -0600 Subject: [PATCH 042/115] Update ticket.js --- src/services/ticket.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/ticket.js b/src/services/ticket.js index 35ba6b2d4..6cb5f9cef 100644 --- a/src/services/ticket.js +++ b/src/services/ticket.js @@ -158,7 +158,7 @@ export async function createTicket(guild, member, categoryId, reason = 'No reaso const priorityInfo = PRIORITY_MAP[priority] || PRIORITY_MAP.none; const embedTitle = actualOptions.embedTitle || `Ticket #${ticketNumber} - ${usernameOrTag(member)}`; const embedDescription = actualOptions.embedDescription || - `${member.toString()}, thanks for creating a ticket!\n\n**Reason:** ${reason}\n**Priority:** ${priorityInfo.emoji} ${priorityInfo.label}`; + `${member.toString()}, Please wait while a staff member reviews your ticket!\n\n**Reason:** ${reason}\n**Priority:** ${priorityInfo.emoji} ${priorityInfo.label}`; const embed = createEmbed({ title: embedTitle, From bfc7b7f93c2a67b3c1fc3ce6e421c1173b56242e Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Sat, 20 Jun 2026 21:24:33 -0600 Subject: [PATCH 043/115] Update ticket.js --- src/services/ticket.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/ticket.js b/src/services/ticket.js index 6cb5f9cef..306117a74 100644 --- a/src/services/ticket.js +++ b/src/services/ticket.js @@ -156,7 +156,7 @@ export async function createTicket(guild, member, categoryId, reason = 'No reaso await saveTicketData(guild.id, channel.id, ticketData); const priorityInfo = PRIORITY_MAP[priority] || PRIORITY_MAP.none; - const embedTitle = actualOptions.embedTitle || `Ticket #${ticketNumber} - ${usernameOrTag(member)}`; + const embedTitle = actualOptions.embedTitle || `Ticket #${ticketNumber}`; const embedDescription = actualOptions.embedDescription || `${member.toString()}, Please wait while a staff member reviews your ticket!\n\n**Reason:** ${reason}\n**Priority:** ${priorityInfo.emoji} ${priorityInfo.label}`; From 4f030a99a4c831a0aa9fe064f703aed7fcf9c64f Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Sat, 20 Jun 2026 22:08:07 -0600 Subject: [PATCH 044/115] Update crime.js --- src/commands/Economy/crime.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/Economy/crime.js b/src/commands/Economy/crime.js index 239c1d9c6..425611ca4 100644 --- a/src/commands/Economy/crime.js +++ b/src/commands/Economy/crime.js @@ -58,7 +58,7 @@ export default { } if (now < lastCrime + CRIME_COOLDOWN) { - const timeLeft = Math.ceil((lastCrime + CRIME_COOLDOWN - now) / (1000 * 60)); + const timeLeft = Math.ceil((lastCrime + CRIME_COOLDOWN - now) / (1000 * 1)); throw createError( "Crime cooldown active", ErrorTypes.RATE_LIMIT, From 71ea145c88628b3af7a58250ecea68124d859f1c Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Sat, 20 Jun 2026 22:10:39 -0600 Subject: [PATCH 045/115] Update crime.js --- src/commands/Economy/crime.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/commands/Economy/crime.js b/src/commands/Economy/crime.js index 425611ca4..e629ddbc1 100644 --- a/src/commands/Economy/crime.js +++ b/src/commands/Economy/crime.js @@ -11,11 +11,11 @@ const FAILURE_RATE = 0.4; const JAIL_TIME = 2 * 60 * 60 * 1000; const CRIME_TYPES = [ - { name: "Pickpocketing", min: 100, max: 500, risk: 0.3 }, - { name: "Burglary", min: 300, max: 1000, risk: 0.4 }, - { name: "Bank Heist", min: 1000, max: 5000, risk: 0.6 }, - { name: "Art Theft", min: 2000, max: 10000, risk: 0.7 }, - { name: "Cybercrime", min: 5000, max: 20000, risk: 0.8 }, + { name: "Pickpocketing", min: 1, max: 1, risk: 0.3 }, + { name: "Burglary", min: 1, max: 1, risk: 0.4 }, + { name: "Bank Heist", min: 1, max: 1, risk: 0.6 }, + { name: "Art Theft", min: 1, max: 1, risk: 0.7 }, + { name: "Cybercrime", min: 1, max: 1, risk: 0.8 }, ]; export default { @@ -58,7 +58,7 @@ export default { } if (now < lastCrime + CRIME_COOLDOWN) { - const timeLeft = Math.ceil((lastCrime + CRIME_COOLDOWN - now) / (1000 * 1)); + const timeLeft = Math.ceil((lastCrime + CRIME_COOLDOWN - now) / (1000 * 60)); throw createError( "Crime cooldown active", ErrorTypes.RATE_LIMIT, From 0dc9688116ffa6a826901ffe970e5a092c72fb11 Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Sat, 20 Jun 2026 22:15:34 -0600 Subject: [PATCH 046/115] Update crime.js --- src/commands/Economy/crime.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/Economy/crime.js b/src/commands/Economy/crime.js index e629ddbc1..bd10f6505 100644 --- a/src/commands/Economy/crime.js +++ b/src/commands/Economy/crime.js @@ -4,7 +4,7 @@ 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 CRIME_COOLDOWN = 1 * 60 * 1000; const MIN_CRIME_AMOUNT = 100; const MAX_CRIME_AMOUNT = 2000; const FAILURE_RATE = 0.4; From df7ada90073d071994a3f21e8ad3f2a3de6689d4 Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Sat, 20 Jun 2026 22:17:47 -0600 Subject: [PATCH 047/115] Update crime.js --- src/commands/Economy/crime.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/Economy/crime.js b/src/commands/Economy/crime.js index bd10f6505..f342e0961 100644 --- a/src/commands/Economy/crime.js +++ b/src/commands/Economy/crime.js @@ -4,11 +4,11 @@ import { getEconomyData, setEconomyData } from '../../utils/economy.js'; import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; -const CRIME_COOLDOWN = 1 * 60 * 1000; +const CRIME_COOLDOWN = 1 * 1 * 1000; const MIN_CRIME_AMOUNT = 100; const MAX_CRIME_AMOUNT = 2000; const FAILURE_RATE = 0.4; -const JAIL_TIME = 2 * 60 * 60 * 1000; +const JAIL_TIME = 1 * 1 * 1 * 1000; const CRIME_TYPES = [ { name: "Pickpocketing", min: 1, max: 1, risk: 0.3 }, From eb05880d98b8ba65c0c389b003657e9fffc85096 Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Sun, 21 Jun 2026 20:43:08 -0600 Subject: [PATCH 048/115] Added sticky to the commands --- src/commands/Utility/sticky.js | 179 +++++++++++++++++++++++++++++++++ src/events/messageCreate.js | 106 ++++++++----------- 2 files changed, 221 insertions(+), 64 deletions(-) create mode 100644 src/commands/Utility/sticky.js diff --git a/src/commands/Utility/sticky.js b/src/commands/Utility/sticky.js new file mode 100644 index 000000000..8a52801c9 --- /dev/null +++ b/src/commands/Utility/sticky.js @@ -0,0 +1,179 @@ +import { + SlashCommandBuilder, + PermissionFlagsBits, + EmbedBuilder, + MessageFlags, +} from 'discord.js'; +import { successEmbed } from '../../utils/embeds.js'; +import { logger } from '../../utils/logger.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; +import { getFromDb, setInDb } from '../../utils/database.js'; + +const STICKY_KEY = (guildId, channelId) => `sticky_${guildId}_${channelId}`; + +export async function getSticky(guildId, channelId) { + return await getFromDb(STICKY_KEY(guildId, channelId), null); +} + +export async function saveSticky(guildId, channelId, data) { + await setInDb(STICKY_KEY(guildId, channelId), data); +} + +export async function deleteSticky(guildId, channelId) { + await setInDb(STICKY_KEY(guildId, channelId), null); +} + +export default { + data: new SlashCommandBuilder() + .setName('sticky') + .setDescription('Manage sticky messages in channels') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages) + + .addSubcommand(sub => + sub.setName('set') + .setDescription('Set a sticky message in a channel') + .addChannelOption(opt => + opt.setName('channel') + .setDescription('The channel to set the sticky in') + .setRequired(true) + ) + .addStringOption(opt => + opt.setName('message') + .setDescription('The sticky message content') + .setRequired(true) + ) + .addStringOption(opt => + opt.setName('title') + .setDescription('Optional title for the sticky embed') + .setRequired(false) + ) + .addStringOption(opt => + opt.setName('color') + .setDescription('Embed color') + .setRequired(false) + .addChoices( + { name: 'Yellow (default)', value: '0xF1C40F' }, + { name: 'Blue', value: '0x3498DB' }, + { name: 'Green', value: '0x2ECC71' }, + { name: 'Red', value: '0xE74C3C' }, + { name: 'Purple', value: '0x9B59B6' }, + { name: 'White', value: '0xFFFFFF' }, + ) + ) + ) + + .addSubcommand(sub => + sub.setName('remove') + .setDescription('Remove the sticky message from a channel') + .addChannelOption(opt => + opt.setName('channel') + .setDescription('The channel to remove the sticky from') + .setRequired(true) + ) + ) + + .addSubcommand(sub => + sub.setName('list') + .setDescription('List all sticky messages in this server') + ), + + category: 'utility', + + async execute(interaction, config, client) { + try { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + const sub = interaction.options.getSubcommand(); + + if (sub === 'set') { + const channel = interaction.options.getChannel('channel'); + const message = interaction.options.getString('message'); + const title = interaction.options.getString('title') || '📌 Sticky Message'; + const colorStr = interaction.options.getString('color') || '0xF1C40F'; + + // Check if sticky already exists and delete old message + const existing = await getSticky(interaction.guild.id, channel.id); + if (existing?.messageId) { + const oldMsg = await channel.messages.fetch(existing.messageId).catch(() => null); + if (oldMsg) await oldMsg.delete().catch(() => {}); + } + + // Send the sticky + const embed = new EmbedBuilder() + .setTitle(title) + .setDescription(message) + .setColor(parseInt(colorStr, 16)) + .setFooter({ text: '📌 Sticky Message' }) + .setTimestamp(); + + const sentMsg = await channel.send({ embeds: [embed] }); + + // Save sticky config + await saveSticky(interaction.guild.id, channel.id, { + channelId: channel.id, + guildId: interaction.guild.id, + messageId: sentMsg.id, + message, + title, + color: colorStr, + setBy: interaction.user.id, + setAt: new Date().toISOString(), + }); + + await InteractionHelper.universalReply(interaction, { + embeds: [successEmbed('📌 Sticky Set', `Sticky message has been set in <#${channel.id}>.`)], + }); + + } else if (sub === 'remove') { + const channel = interaction.options.getChannel('channel'); + const sticky = await getSticky(interaction.guild.id, channel.id); + + if (!sticky) { + throw new TitanBotError('No sticky', ErrorTypes.USER_INPUT, `There is no sticky message in <#${channel.id}>.`, { subtype: 'not_found' }); + } + + // Delete the sticky message + if (sticky.messageId) { + const msg = await channel.messages.fetch(sticky.messageId).catch(() => null); + if (msg) await msg.delete().catch(() => {}); + } + + await deleteSticky(interaction.guild.id, channel.id); + + await InteractionHelper.universalReply(interaction, { + embeds: [successEmbed('🗑️ Sticky Removed', `Sticky message has been removed from <#${channel.id}>.`)], + }); + + } else if (sub === 'list') { + // Scan all channels for stickies + const stickies = []; + for (const [, channel] of interaction.guild.channels.cache) { + const sticky = await getSticky(interaction.guild.id, channel.id); + if (sticky) stickies.push(sticky); + } + + if (stickies.length === 0) { + return InteractionHelper.universalReply(interaction, { + embeds: [new EmbedBuilder().setColor(0x3498DB).setDescription('No sticky messages are set in this server.')], + }); + } + + const embed = new EmbedBuilder() + .setColor(0xF1C40F) + .setTitle('📌 Sticky Messages') + .setDescription(stickies.map(s => + `<#${s.channelId}> — **${s.title}**\n> ${s.message.length > 80 ? s.message.slice(0, 80) + '…' : s.message}` + ).join('\n\n')) + .setFooter({ text: `${stickies.length} sticky message(s)` }) + .setTimestamp(); + + await InteractionHelper.universalReply(interaction, { embeds: [embed] }); + } + + } catch (error) { + logger.error('Sticky command error:', error); + await handleInteractionError(interaction, error, { subtype: 'sticky_failed' }); + } + }, +}; diff --git a/src/events/messageCreate.js b/src/events/messageCreate.js index 07b556f5d..0c26022e1 100644 --- a/src/events/messageCreate.js +++ b/src/events/messageCreate.js @@ -1,4 +1,4 @@ -import { Events } from 'discord.js'; +import { Events } from 'discord.js'; import { logger } from '../utils/logger.js'; import { getLevelingConfig, getUserLevelData } from '../services/leveling.js'; import { addXp } from '../services/xpSystem.js'; @@ -17,6 +17,8 @@ import { isValidCountingMessage, recordCorrectCount, } from '../services/countingGameService.js'; +import { getSticky, saveSticky } from '../commands/utility/sticky.js'; +import { EmbedBuilder } from 'discord.js'; const MESSAGE_XP_RATE_LIMIT_ATTEMPTS = 12; const MESSAGE_XP_RATE_LIMIT_WINDOW_MS = 10000; @@ -39,27 +41,60 @@ export default { await handlePrefixCommand(message, client); await handleLeveling(message, client); + + await handleSticky(message); } catch (error) { logger.error('Error in messageCreate event:', error); } } }; +async function handleSticky(message) { + try { + const sticky = await getSticky(message.guild.id, message.channel.id); + if (!sticky) return; + + // Delete the old sticky message + if (sticky.messageId) { + const oldMsg = await message.channel.messages.fetch(sticky.messageId).catch(() => null); + if (oldMsg) await oldMsg.delete().catch(() => {}); + } + + // Repost the sticky at the bottom + const embed = new EmbedBuilder() + .setTitle(sticky.title || '📌 Sticky Message') + .setDescription(sticky.message) + .setColor(parseInt(sticky.color || '0xF1C40F', 16)) + .setFooter({ text: '📌 Sticky Message' }) + .setTimestamp(); + + const newMsg = await message.channel.send({ embeds: [embed] }); + + // Update the stored message ID + await saveSticky(message.guild.id, message.channel.id, { + ...sticky, + messageId: newMsg.id, + }); + } catch (error) { + logger.error('Error handling sticky message:', error); + } +} + async function handleFAQ(message) { const faqs = { - "How do I join team syne": "Create a ticket https://discord.com/channels/1382512078585200642/1396618369209340084", - "how do i make a ticket": "To join team syne click here https://discord.com/channels/1382512078585200642/1514857141125644429", - "what are the rules": "Please check the rules channel for our server rules! https://discord.com/channels/1382512078585200642/1382512079486845073", + "how do i get started": "Welcome! Check out our rules and grab your roles to get started!", + "how do i make a ticket": "Use the `/ticket` command to open a support ticket!", + "what are the rules": "Please check the rules channel for our server rules!", "how do i level up": "Send messages in the server to earn XP and level up!", "what commands are available": "Type `/` to see all available commands!", - "how do i get roles": "Head over to the roles channel and pick the ones you want! https://discord.com/channels/1382512078585200642/1514096776100053172", + "how do i get roles": "Head over to the roles channel and pick the ones you want!", "who made this bot": "This bot was built with TeamSyne — a powerful all-in-one Discord assistant!", }; - const content = message.content; + const content = message.content.toLowerCase(); for (const [keyword, reply] of Object.entries(faqs)) { - if (isFaqMatch(content, keyword)) { + if (content.includes(keyword)) { await message.reply(reply); return true; } @@ -68,63 +103,6 @@ async function handleFAQ(message) { return false; } -function normalizeText(text) { - return text - .toLowerCase() - .replace(/[^\u0000-\u007F]+/g, '') - .replace(/[^\n\w\s]/g, ' ') - .replace(/\s+/g, ' ') - .trim(); -} - -function getLevenshteinDistance(a, b) { - const matrix = Array.from({ length: b.length + 1 }, () => []); - for (let i = 0; i <= b.length; i += 1) { - matrix[i][0] = i; - } - for (let j = 0; j <= a.length; j += 1) { - matrix[0][j] = j; - } - - for (let i = 1; i <= b.length; i += 1) { - for (let j = 1; j <= a.length; j += 1) { - matrix[i][j] = Math.min( - matrix[i - 1][j] + 1, - matrix[i][j - 1] + 1, - matrix[i - 1][j - 1] + (a[j - 1] === b[i - 1] ? 0 : 1), - ); - } - } - - return matrix[b.length][a.length]; -} - -function isFaqMatch(messageContent, keyword) { - const normalizedContent = normalizeText(messageContent); - const normalizedKeyword = normalizeText(keyword); - - if (normalizedContent.includes(normalizedKeyword)) { - return true; - } - - const messageWords = normalizedContent.split(' ').filter(Boolean); - const keywordWords = normalizedKeyword.split(' ').filter(Boolean); - if (messageWords.length > keywordWords.length + 2) { - return false; - } - let matchedWords = 0; - - for (const keywordWord of keywordWords) { - const maxDistance = Math.max(1, Math.floor(keywordWord.length * 0.25)); - const bestDistance = messageWords.reduce((best, word) => Math.min(best, getLevenshteinDistance(keywordWord, word)), Infinity); - if (bestDistance <= maxDistance) { - matchedWords += 1; - } - } - - return matchedWords >= Math.max(2, keywordWords.length - 1); -} - async function handlePrefixCommand(message, client) { try { const guildConfig = await getGuildConfig(client, message.guild.id); From eff8f8124189003bf473238a8ac391df4f123ce5 Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Sun, 21 Jun 2026 20:47:19 -0600 Subject: [PATCH 049/115] Update messageCreate.js --- src/events/messageCreate.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/events/messageCreate.js b/src/events/messageCreate.js index 0c26022e1..d854fa895 100644 --- a/src/events/messageCreate.js +++ b/src/events/messageCreate.js @@ -27,7 +27,15 @@ export default { name: Events.MessageCreate, async execute(message, client) { try { - if (message.author.bot || !message.guild) return; + if (!message.guild) return; + + // Handle sticky BEFORE bot check so non-bot messages trigger repost + // But skip if the message author is the bot itself to prevent loops + if (!message.author.bot) { + await handleSticky(message); + } + + if (message.author.bot) return; logger.debug(`Message received from ${message.author.tag}: ${message.content}`); @@ -41,8 +49,6 @@ export default { await handlePrefixCommand(message, client); await handleLeveling(message, client); - - await handleSticky(message); } catch (error) { logger.error('Error in messageCreate event:', error); } From 22899a850d1e7af91fce595dff82d3dfed78cca7 Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Sun, 21 Jun 2026 20:59:54 -0600 Subject: [PATCH 050/115] Update messageCreate.js --- src/events/messageCreate.js | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/events/messageCreate.js b/src/events/messageCreate.js index d854fa895..72fec468d 100644 --- a/src/events/messageCreate.js +++ b/src/events/messageCreate.js @@ -55,11 +55,26 @@ export default { } }; +// Track cooldowns per channel to avoid sticky spam +const stickyCooldowns = new Map(); +const STICKY_COOLDOWN_MS = 3000; + async function handleSticky(message) { try { const sticky = await getSticky(message.guild.id, message.channel.id); if (!sticky) return; + // Skip if the message sent IS the sticky itself (prevent loops) + if (message.id === sticky.messageId) return; + + // Cooldown check — only repost once every 3 seconds per channel + const cooldownKey = `${message.guild.id}_${message.channel.id}`; + const lastRepost = stickyCooldowns.get(cooldownKey) || 0; + const now = Date.now(); + + if (now - lastRepost < STICKY_COOLDOWN_MS) return; + stickyCooldowns.set(cooldownKey, now); + // Delete the old sticky message if (sticky.messageId) { const oldMsg = await message.channel.messages.fetch(sticky.messageId).catch(() => null); @@ -286,4 +301,4 @@ async function handleLeveling(message, client) { } catch (error) { logger.error('Error handling leveling for message:', error); } -} +} \ No newline at end of file From 71c51515f2dd0c31aae28a148044ccadb5dd5f7b Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:55:37 -0600 Subject: [PATCH 051/115] Update messageCreate.js --- src/events/messageCreate.js | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/events/messageCreate.js b/src/events/messageCreate.js index 72fec468d..74a7af5ff 100644 --- a/src/events/messageCreate.js +++ b/src/events/messageCreate.js @@ -1,4 +1,4 @@ -import { Events } from 'discord.js'; +import { Events } from 'discord.js'; import { logger } from '../utils/logger.js'; import { getLevelingConfig, getUserLevelData } from '../services/leveling.js'; import { addXp } from '../services/xpSystem.js'; @@ -29,12 +29,6 @@ export default { try { if (!message.guild) return; - // Handle sticky BEFORE bot check so non-bot messages trigger repost - // But skip if the message author is the bot itself to prevent loops - if (!message.author.bot) { - await handleSticky(message); - } - if (message.author.bot) return; logger.debug(`Message received from ${message.author.tag}: ${message.content}`); @@ -49,6 +43,8 @@ export default { await handlePrefixCommand(message, client); await handleLeveling(message, client); + + await handleSticky(message); } catch (error) { logger.error('Error in messageCreate event:', error); } From 196ee1b0025b555a7bd456ebb4903c2c26f4360b Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:00:13 -0600 Subject: [PATCH 052/115] Update messageCreate.js --- src/events/messageCreate.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/events/messageCreate.js b/src/events/messageCreate.js index 74a7af5ff..cb4c87735 100644 --- a/src/events/messageCreate.js +++ b/src/events/messageCreate.js @@ -35,6 +35,7 @@ export default { const countingProcessed = await handleCountingGame(message, client); if (countingProcessed) { + await handleSticky(message); return; } From 49421a3d647fa718a2a736392b49f241e2c75e94 Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:07:43 -0600 Subject: [PATCH 053/115] Update messageCreate.js --- src/events/messageCreate.js | 34 +++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/src/events/messageCreate.js b/src/events/messageCreate.js index cb4c87735..7b245ac8a 100644 --- a/src/events/messageCreate.js +++ b/src/events/messageCreate.js @@ -18,7 +18,7 @@ import { recordCorrectCount, } from '../services/countingGameService.js'; import { getSticky, saveSticky } from '../commands/utility/sticky.js'; -import { EmbedBuilder } from 'discord.js'; +import { EmbedBuilder, PermissionsBitField } from 'discord.js'; const MESSAGE_XP_RATE_LIMIT_ATTEMPTS = 12; const MESSAGE_XP_RATE_LIMIT_WINDOW_MS = 10000; @@ -45,7 +45,10 @@ export default { await handleLeveling(message, client); - await handleSticky(message); + const stickyReposted = await handleSticky(message); + if (stickyReposted) { + logger.info(`Sticky message reposted in ${message.channel.id} after message ${message.id}`); + } } catch (error) { logger.error('Error in messageCreate event:', error); } @@ -59,19 +62,36 @@ const STICKY_COOLDOWN_MS = 3000; async function handleSticky(message) { try { const sticky = await getSticky(message.guild.id, message.channel.id); - if (!sticky) return; + if (!sticky) { + logger.debug(`No sticky configured for channel ${message.channel.id}`); + return false; + } // Skip if the message sent IS the sticky itself (prevent loops) - if (message.id === sticky.messageId) return; + if (message.id === sticky.messageId) { + logger.debug(`Received sticky message itself in ${message.channel.id}, skipping repost.`); + return false; + } // Cooldown check — only repost once every 3 seconds per channel const cooldownKey = `${message.guild.id}_${message.channel.id}`; const lastRepost = stickyCooldowns.get(cooldownKey) || 0; const now = Date.now(); - if (now - lastRepost < STICKY_COOLDOWN_MS) return; + if (now - lastRepost < STICKY_COOLDOWN_MS) { + logger.debug(`Sticky cooldown active for channel ${message.channel.id}`); + return false; + } stickyCooldowns.set(cooldownKey, now); + // Permissions check + const botMember = await message.guild.members.fetch(message.client.user.id).catch(() => null); + const channelPerms = message.channel.permissionsFor(botMember); + if (!channelPerms || !channelPerms.has([PermissionsBitField.Flags.SendMessages, PermissionsBitField.Flags.ManageMessages])) { + logger.warn(`Missing sticky permissions in channel ${message.channel.id}. CanSend: ${channelPerms?.has(PermissionsBitField.Flags.SendMessages)}, CanManage: ${channelPerms?.has(PermissionsBitField.Flags.ManageMessages)}`); + return false; + } + // Delete the old sticky message if (sticky.messageId) { const oldMsg = await message.channel.messages.fetch(sticky.messageId).catch(() => null); @@ -93,8 +113,12 @@ async function handleSticky(message) { ...sticky, messageId: newMsg.id, }); + + logger.info(`Sticky reposted successfully in channel ${message.channel.id}`); + return true; } catch (error) { logger.error('Error handling sticky message:', error); + return false; } } From a110d03b2a4055d166e97bc1675d8e2efe7cceef Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:17:53 -0600 Subject: [PATCH 054/115] Update messageCreate.js --- src/events/messageCreate.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/events/messageCreate.js b/src/events/messageCreate.js index 7b245ac8a..6a898b415 100644 --- a/src/events/messageCreate.js +++ b/src/events/messageCreate.js @@ -57,7 +57,7 @@ export default { // Track cooldowns per channel to avoid sticky spam const stickyCooldowns = new Map(); -const STICKY_COOLDOWN_MS = 3000; +const STICKY_COOLDOWN_MS = 1; async function handleSticky(message) { try { From 110eaf48e4a3ef1e0ddd6465ca7bcb627dc5016b Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:33:36 -0600 Subject: [PATCH 055/115] Update messageCreate.js --- src/events/messageCreate.js | 53 ++++++++++++++++++++++--------------- 1 file changed, 32 insertions(+), 21 deletions(-) diff --git a/src/events/messageCreate.js b/src/events/messageCreate.js index 6a898b415..6b17b32d6 100644 --- a/src/events/messageCreate.js +++ b/src/events/messageCreate.js @@ -26,31 +26,33 @@ const MESSAGE_XP_RATE_LIMIT_WINDOW_MS = 10000; export default { name: Events.MessageCreate, async execute(message, client) { - try { - if (!message.guild) return; + if (!message.guild) return; + if (message.author.bot) return; - if (message.author.bot) return; + logger.debug(`Message received from ${message.author.tag}: ${message.content}`); - logger.debug(`Message received from ${message.author.tag}: ${message.content}`); + let stickyReposted = false; + try { const countingProcessed = await handleCountingGame(message, client); if (countingProcessed) { - await handleSticky(message); return; } await handleFAQ(message); - await handlePrefixCommand(message, client); - await handleLeveling(message, client); - - const stickyReposted = await handleSticky(message); - if (stickyReposted) { - logger.info(`Sticky message reposted in ${message.channel.id} after message ${message.id}`); - } } catch (error) { logger.error('Error in messageCreate event:', error); + } finally { + try { + stickyReposted = await handleSticky(message); + if (stickyReposted) { + logger.info(`Sticky message reposted in ${message.channel.id} after message ${message.id}`); + } + } catch (stickyError) { + logger.error('Error reposting sticky message in finally block:', stickyError); + } } } }; @@ -73,7 +75,7 @@ async function handleSticky(message) { return false; } - // Cooldown check — only repost once every 3 seconds per channel + // Cooldown check — only repost once every few seconds per channel const cooldownKey = `${message.guild.id}_${message.channel.id}`; const lastRepost = stickyCooldowns.get(cooldownKey) || 0; const now = Date.now(); @@ -84,18 +86,27 @@ async function handleSticky(message) { } stickyCooldowns.set(cooldownKey, now); - // Permissions check - const botMember = await message.guild.members.fetch(message.client.user.id).catch(() => null); - const channelPerms = message.channel.permissionsFor(botMember); - if (!channelPerms || !channelPerms.has([PermissionsBitField.Flags.SendMessages, PermissionsBitField.Flags.ManageMessages])) { - logger.warn(`Missing sticky permissions in channel ${message.channel.id}. CanSend: ${channelPerms?.has(PermissionsBitField.Flags.SendMessages)}, CanManage: ${channelPerms?.has(PermissionsBitField.Flags.ManageMessages)}`); + const channelPerms = message.channel.permissionsFor(message.guild.members.me || message.client.user); + if (!channelPerms || !channelPerms.has(PermissionsBitField.Flags.SendMessages)) { + logger.warn(`Missing send permission for sticky in channel ${message.channel.id}.`); return false; } - // Delete the old sticky message + let oldMsgDeleted = false; if (sticky.messageId) { - const oldMsg = await message.channel.messages.fetch(sticky.messageId).catch(() => null); - if (oldMsg) await oldMsg.delete().catch(() => {}); + const oldMsg = await message.channel.messages.fetch(sticky.messageId, { cache: false, force: true }).catch(() => null); + if (oldMsg) { + if (oldMsg.deletable) { + await oldMsg.delete().catch(error => { + logger.warn(`Failed to delete old sticky message ${sticky.messageId} in ${message.channel.id}:`, error); + }); + oldMsgDeleted = true; + } else { + logger.warn(`Old sticky message ${sticky.messageId} in ${message.channel.id} is not deletable.`); + } + } else { + logger.debug(`Old sticky message ${sticky.messageId} not found in ${message.channel.id}.`); + } } // Repost the sticky at the bottom From ad645c576b45802289848d3a319999f0f6a3706f Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:41:02 -0600 Subject: [PATCH 056/115] Update messageCreate.js --- src/events/messageCreate.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/events/messageCreate.js b/src/events/messageCreate.js index 6b17b32d6..0f4fafd91 100644 --- a/src/events/messageCreate.js +++ b/src/events/messageCreate.js @@ -59,15 +59,17 @@ export default { // Track cooldowns per channel to avoid sticky spam const stickyCooldowns = new Map(); -const STICKY_COOLDOWN_MS = 1; +const STICKY_COOLDOWN_MS = 2 * 1000; // 2 seconds cooldown per channel async function handleSticky(message) { try { + logger.debug(`handleSticky triggered for channel ${message.channel.id}, message ${message.id}`); const sticky = await getSticky(message.guild.id, message.channel.id); if (!sticky) { logger.debug(`No sticky configured for channel ${message.channel.id}`); return false; } + logger.debug(`Sticky config found for channel ${message.channel.id}: ${JSON.stringify({ messageId: sticky.messageId, title: sticky.title, channelId: sticky.channelId })}`); // Skip if the message sent IS the sticky itself (prevent loops) if (message.id === sticky.messageId) { From ba9e9dffa007c4497679c123ecf27a44aa9a5796 Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:58:30 -0600 Subject: [PATCH 057/115] Update welcome.js --- src/commands/Welcome/welcome.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/commands/Welcome/welcome.js b/src/commands/Welcome/welcome.js index 95fd83346..4db823f49 100644 --- a/src/commands/Welcome/welcome.js +++ b/src/commands/Welcome/welcome.js @@ -4,6 +4,7 @@ import { getWelcomeConfig, updateWelcomeConfig } from '../../utils/database.js'; import { formatWelcomeMessage } from '../../utils/welcome.js'; import { logger } from '../../utils/logger.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; export default { data: new SlashCommandBuilder() @@ -65,7 +66,7 @@ export default { const existingConfig = await getWelcomeConfig(client, guild.id); if (existingConfig?.channelId) { logger.info(`[Welcome] Setup blocked because config already exists in channel ${existingConfig.channelId} for guild ${guild.id}`); - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Welcome is already configured for <#${existingConfig.channelId}>. Use **/welcome config** to customize channel, message, ping, or image.' }); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `Welcome is already configured for <#${existingConfig.channelId}>. Use **/welcome config** to customize channel, message, ping, or image.` }); } if (!message || message.trim().length === 0) { @@ -78,7 +79,7 @@ export default { new URL(image); } catch (e) { logger.warn(`[Welcome] Invalid image URL provided by ${interaction.user.tag}: ${image}`); - return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Please provide a valid image URL (must start with http:// or https://' }); + return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Please provide a valid image URL (must start with http:// or https://)' }); } } From d01a0e40a4b8c7199ad8c80ac8a296244568bde9 Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:22:38 -0600 Subject: [PATCH 058/115] Update guildMemberAdd.js --- src/events/guildMemberAdd.js | 84 ++++++++++++++++++++++++++---------- 1 file changed, 61 insertions(+), 23 deletions(-) diff --git a/src/events/guildMemberAdd.js b/src/events/guildMemberAdd.js index 4bf27f633..81572ead6 100644 --- a/src/events/guildMemberAdd.js +++ b/src/events/guildMemberAdd.js @@ -1,35 +1,73 @@ import { Events, EmbedBuilder } from 'discord.js'; import { logger } from '../utils/logger.js'; -import { getFromDb } from '../utils/database.js'; +import { getFromDb, getWelcomeConfig } from '../utils/database.js'; +import { formatWelcomeMessage } from '../utils/welcome.js'; -const CONFIG_KEY = (guildId) => `announcement_config_${guildId}`; +const ANNOUNCEMENT_KEY = (guildId) => `announcement_config_${guildId}`; export default { name: Events.GuildMemberAdd, async execute(member, client) { try { - const config = await getFromDb(CONFIG_KEY(member.guild.id), {}); - if (!config.welcomeChannelId) return; - - const channel = member.guild.channels.cache.get(config.welcomeChannelId) - || await member.guild.channels.fetch(config.welcomeChannelId).catch(() => null); - if (!channel) return; - - const embed = new EmbedBuilder() - .setColor(0x2ECC71) - .setTitle(`👋 Welcome to ${member.guild.name}!`) - .setDescription(`Hey <@${member.id}>, welcome to **${member.guild.name}**! We're glad to have you here.\n\nMake sure to check out the rules and grab your roles!`) - .setThumbnail(member.user.displayAvatarURL({ dynamic: true, size: 256 })) - .addFields( - { name: 'Member', value: `<@${member.id}>`, inline: true }, - { name: 'Account Created', value: ``, inline: true }, - { name: 'Member Count', value: `#${member.guild.memberCount}`, inline: true }, - ) - .setTimestamp(); - - await channel.send({ embeds: [embed] }); + // --- Primary welcome system (set via /welcome setup) --- + const welcomeConfig = await getWelcomeConfig(client, member.guild.id).catch(() => null); + + if (welcomeConfig?.enabled && welcomeConfig?.channelId) { + const welcomeChannel = member.guild.channels.cache.get(welcomeConfig.channelId) + || await member.guild.channels.fetch(welcomeConfig.channelId).catch(() => null); + + if (welcomeChannel) { + const formattedMessage = formatWelcomeMessage(welcomeConfig.welcomeMessage || 'Welcome {user}!', { + user: member.user, + guild: member.guild, + }); + + const embed = new EmbedBuilder() + .setColor(0x2ECC71) + .setDescription(formattedMessage) + .setThumbnail(member.user.displayAvatarURL({ dynamic: true, size: 256 })) + .addFields( + { name: 'Member', value: `<@${member.id}>`, inline: true }, + { name: 'Account Created', value: ``, inline: true }, + { name: 'Member Count', value: `#${member.guild.memberCount}`, inline: true }, + ) + .setTimestamp(); + + if (welcomeConfig.welcomeImage) { + embed.setImage(welcomeConfig.welcomeImage); + } + + const pingContent = welcomeConfig.welcomePing ? `<@${member.id}>` : undefined; + await welcomeChannel.send({ content: pingContent, embeds: [embed] }); + logger.info(`Welcome message sent for ${member.user.tag} in ${member.guild.name}`); + } + } + + // --- Announcement system welcome (set via /announcement setchannel welcome) --- + const announcementConfig = await getFromDb(ANNOUNCEMENT_KEY(member.guild.id), {}); + if (announcementConfig?.welcomeChannelId && announcementConfig.welcomeChannelId !== welcomeConfig?.channelId) { + const announcementChannel = member.guild.channels.cache.get(announcementConfig.welcomeChannelId) + || await member.guild.channels.fetch(announcementConfig.welcomeChannelId).catch(() => null); + + if (announcementChannel) { + const embed = new EmbedBuilder() + .setColor(0x2ECC71) + .setTitle(`👋 Welcome to ${member.guild.name}!`) + .setDescription(`Hey <@${member.id}>, welcome to **${member.guild.name}**! We're glad to have you here.\n\nMake sure to check out the rules and grab your roles!`) + .setThumbnail(member.user.displayAvatarURL({ dynamic: true, size: 256 })) + .addFields( + { name: 'Member', value: `<@${member.id}>`, inline: true }, + { name: 'Account Created', value: ``, inline: true }, + { name: 'Member Count', value: `#${member.guild.memberCount}`, inline: true }, + ) + .setTimestamp(); + + await announcementChannel.send({ embeds: [embed] }); + } + } + } catch (error) { logger.error('Error sending welcome message:', error); } }, -}; +}; \ No newline at end of file From 7a940bc61c0f0183fc2b333781e803cfea039082 Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:15:16 -0600 Subject: [PATCH 059/115] Update welcome.js --- src/commands/Welcome/welcome.js | 165 +++++++++++--------------------- 1 file changed, 54 insertions(+), 111 deletions(-) diff --git a/src/commands/Welcome/welcome.js b/src/commands/Welcome/welcome.js index 4db823f49..74c70a8ae 100644 --- a/src/commands/Welcome/welcome.js +++ b/src/commands/Welcome/welcome.js @@ -1,124 +1,67 @@ -import { getColor } from '../../config/bot.js'; -import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, EmbedBuilder, MessageFlags } from 'discord.js'; -import { getWelcomeConfig, updateWelcomeConfig } from '../../utils/database.js'; -import { formatWelcomeMessage } from '../../utils/welcome.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; +import { Events, EmbedBuilder } from 'discord.js'; +import { logger } from '../utils/logger.js'; +import { getFromDb, getWelcomeConfig } from '../utils/database.js'; +import { formatWelcomeMessage } from '../utils/welcome.js'; -export default { - data: new SlashCommandBuilder() - .setName('welcome') - .setDescription('Configure the welcome system') - .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) - .addSubcommand(subcommand => - subcommand - .setName('setup') - .setDescription('Set up the welcome message') - .addChannelOption(option => - option.setName('channel') - .setDescription('The channel to send welcome messages to') - .addChannelTypes(ChannelType.GuildText) - .setRequired(true)) - .addStringOption(option => - option.setName('message') - .setDescription('Welcome message. Variables: {user}, {username}, {server}, {memberCount}') - .setRequired(true)) - .addStringOption(option => - option.setName('image') - .setDescription('URL of the image to include in the welcome message') - .setRequired(false)) - .addBooleanOption(option => - option.setName('ping') - .setDescription('Whether to ping the user in the welcome message') - .setRequired(false))), - - async execute(interaction) { - try { - const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) { - logger.warn(`Welcome interaction defer failed`, { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'welcome' - }); - return; - } - } catch (deferError) { - logger.error(`Welcome defer error`, { error: deferError.message }); - return; - } +const ANNOUNCEMENT_KEY = (guildId) => `announcement_config_${guildId}`; - const { options, guild, client } = interaction; - - if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) { - return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need the **Manage Server** permission to use `/welcome`.' }); - } - - const subcommand = options.getSubcommand(); +export default { + name: Events.GuildMemberAdd, + async execute(member, client) { + try { + // --- Primary welcome system (set via /welcome setup) --- + const welcomeConfig = await getWelcomeConfig(client, member.guild.id).catch(() => null); - if (subcommand === 'setup') { - const channel = options.getChannel('channel'); - const message = options.getString('message'); - const image = options.getString('image'); - const ping = options.getBoolean('ping') ?? false; + if (welcomeConfig?.enabled && welcomeConfig?.channelId) { + const welcomeChannel = member.guild.channels.cache.get(welcomeConfig.channelId) + || await member.guild.channels.fetch(welcomeConfig.channelId).catch(() => null); - const existingConfig = await getWelcomeConfig(client, guild.id); - if (existingConfig?.channelId) { - logger.info(`[Welcome] Setup blocked because config already exists in channel ${existingConfig.channelId} for guild ${guild.id}`); - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `Welcome is already configured for <#${existingConfig.channelId}>. Use **/welcome config** to customize channel, message, ping, or image.` }); - } - - if (!message || message.trim().length === 0) { - logger.warn(`[Welcome] Empty message provided by ${interaction.user.tag} in ${guild.name}`); - return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Welcome message cannot be empty' }); - } + if (welcomeChannel) { + const formattedMessage = formatWelcomeMessage(welcomeConfig.welcomeMessage || 'Welcome {user}!', { + user: member.user, + guild: member.guild, + }); - if (image) { - try { - new URL(image); - } catch (e) { - logger.warn(`[Welcome] Invalid image URL provided by ${interaction.user.tag}: ${image}`); - return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Please provide a valid image URL (must start with http:// or https://)' }); - } - } + const embed = new EmbedBuilder() + .setColor(0x2ECC71) + .setDescription(formattedMessage) + .setFooter({ text: `${member.guild.name} Management` }) + .setTimestamp(); - try { - await updateWelcomeConfig(client, guild.id, { - enabled: true, - channelId: channel.id, - welcomeMessage: message, - welcomeImage: image || undefined, - welcomePing: ping - }); + if (welcomeConfig.welcomeImage) { + embed.setImage(welcomeConfig.welcomeImage); + } - logger.info(`[Welcome] Setup configured by ${interaction.user.tag} for guild ${guild.name} (${guild.id})`); + // Title line outside the embed, pinging the user + const titleContent = `Welcome to ${member.guild.name} <@${member.id}> 🎉`; - const previewMessage = formatWelcomeMessage(message, { - user: interaction.user, - guild - }); + await welcomeChannel.send({ content: titleContent, embeds: [embed] }); + logger.info(`Welcome message sent for ${member.user.tag} in ${member.guild.name}`); + } + } - const embed = new EmbedBuilder() - .setColor(getColor('success')) - .setTitle('Welcome System Configured') - .setDescription(`Welcome messages will now be sent to ${channel}`) - .addFields( - { name: 'Message Preview', value: previewMessage }, - { name: 'Ping User', value: ping ? 'Yes' : 'No' }, - { name: 'Status', value: 'Enabled' } - ) - .setFooter({ text: 'Tip: Use /welcome config to customize welcome settings' }); + // --- Announcement system welcome (set via /announcement setchannel welcome) --- + const announcementConfig = await getFromDb(ANNOUNCEMENT_KEY(member.guild.id), {}); + if (announcementConfig?.welcomeChannelId && announcementConfig.welcomeChannelId !== welcomeConfig?.channelId) { + const announcementChannel = member.guild.channels.cache.get(announcementConfig.welcomeChannelId) + || await member.guild.channels.fetch(announcementConfig.welcomeChannelId).catch(() => null); - if (image) { - embed.setImage(image); - } + if (announcementChannel) { + const embed = new EmbedBuilder() + .setColor(0x2ECC71) + .setDescription(`Welcome to **${member.guild.name}**! We're glad to have you here.\n\nMake sure to check out the rules and grab your roles!`) + .setFooter({ text: `${member.guild.name} Management` }) + .setTimestamp(); - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - } catch (error) { - logger.error(`[Welcome] Failed to setup welcome system for guild ${guild.id}:`, error); - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while configuring the welcome system. Please try again.' }); - } + await announcementChannel.send({ + content: `Welcome to ${member.guild.name} <@${member.id}> 🎉`, + embeds: [embed], + }); } - }, + } + + } catch (error) { + logger.error('Error sending welcome message:', error); + } + }, }; \ No newline at end of file From abec9944192047c9b3c6c9ace484f292291023be Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:32:16 -0600 Subject: [PATCH 060/115] Update welcome.js --- src/commands/Welcome/welcome.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/Welcome/welcome.js b/src/commands/Welcome/welcome.js index 74c70a8ae..59465b401 100644 --- a/src/commands/Welcome/welcome.js +++ b/src/commands/Welcome/welcome.js @@ -23,7 +23,7 @@ export default { }); const embed = new EmbedBuilder() - .setColor(0x2ECC71) + .setColor(0xFFFFFF) .setDescription(formattedMessage) .setFooter({ text: `${member.guild.name} Management` }) .setTimestamp(); @@ -48,7 +48,7 @@ export default { if (announcementChannel) { const embed = new EmbedBuilder() - .setColor(0x2ECC71) + .setColor(0xFFFFFF) .setDescription(`Welcome to **${member.guild.name}**! We're glad to have you here.\n\nMake sure to check out the rules and grab your roles!`) .setFooter({ text: `${member.guild.name} Management` }) .setTimestamp(); From 99df6d6561363efb0385c87db6c0fe8e56d09334 Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:37:45 -0600 Subject: [PATCH 061/115] Update guildMemberAdd.js --- src/events/guildMemberAdd.js | 32 +++++++++++++------------------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/src/events/guildMemberAdd.js b/src/events/guildMemberAdd.js index 81572ead6..59465b401 100644 --- a/src/events/guildMemberAdd.js +++ b/src/events/guildMemberAdd.js @@ -23,22 +23,19 @@ export default { }); const embed = new EmbedBuilder() - .setColor(0x2ECC71) + .setColor(0xFFFFFF) .setDescription(formattedMessage) - .setThumbnail(member.user.displayAvatarURL({ dynamic: true, size: 256 })) - .addFields( - { name: 'Member', value: `<@${member.id}>`, inline: true }, - { name: 'Account Created', value: ``, inline: true }, - { name: 'Member Count', value: `#${member.guild.memberCount}`, inline: true }, - ) + .setFooter({ text: `${member.guild.name} Management` }) .setTimestamp(); if (welcomeConfig.welcomeImage) { embed.setImage(welcomeConfig.welcomeImage); } - const pingContent = welcomeConfig.welcomePing ? `<@${member.id}>` : undefined; - await welcomeChannel.send({ content: pingContent, embeds: [embed] }); + // Title line outside the embed, pinging the user + const titleContent = `Welcome to ${member.guild.name} <@${member.id}> 🎉`; + + await welcomeChannel.send({ content: titleContent, embeds: [embed] }); logger.info(`Welcome message sent for ${member.user.tag} in ${member.guild.name}`); } } @@ -51,18 +48,15 @@ export default { if (announcementChannel) { const embed = new EmbedBuilder() - .setColor(0x2ECC71) - .setTitle(`👋 Welcome to ${member.guild.name}!`) - .setDescription(`Hey <@${member.id}>, welcome to **${member.guild.name}**! We're glad to have you here.\n\nMake sure to check out the rules and grab your roles!`) - .setThumbnail(member.user.displayAvatarURL({ dynamic: true, size: 256 })) - .addFields( - { name: 'Member', value: `<@${member.id}>`, inline: true }, - { name: 'Account Created', value: ``, inline: true }, - { name: 'Member Count', value: `#${member.guild.memberCount}`, inline: true }, - ) + .setColor(0xFFFFFF) + .setDescription(`Welcome to **${member.guild.name}**! We're glad to have you here.\n\nMake sure to check out the rules and grab your roles!`) + .setFooter({ text: `${member.guild.name} Management` }) .setTimestamp(); - await announcementChannel.send({ embeds: [embed] }); + await announcementChannel.send({ + content: `Welcome to ${member.guild.name} <@${member.id}> 🎉`, + embeds: [embed], + }); } } From d94e94544e3fa8ecaa1bcbbfad6b9f643ce6e197 Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:08:50 -0600 Subject: [PATCH 062/115] Update greet_dashboard.js --- .../Welcome/modules/greet_dashboard.js | 91 ++++++------------- 1 file changed, 28 insertions(+), 63 deletions(-) diff --git a/src/commands/Welcome/modules/greet_dashboard.js b/src/commands/Welcome/modules/greet_dashboard.js index f3889238d..8a11c9d3e 100644 --- a/src/commands/Welcome/modules/greet_dashboard.js +++ b/src/commands/Welcome/modules/greet_dashboard.js @@ -13,9 +13,6 @@ import { MessageFlags, ComponentType, EmbedBuilder, - LabelBuilder, - FileUploadBuilder, - TextDisplayBuilder, } from 'discord.js'; import { InteractionHelper } from '../../../utils/interactionHelper.js'; import { successEmbed } from '../../../utils/embeds.js'; @@ -444,34 +441,19 @@ async function handleWelcomeMessage(selectInteraction, rootInteraction, cfg, gui async function handleWelcomeImage(selectInteraction, rootInteraction, cfg, guildId, client) { const modal = new ModalBuilder() .setCustomId('greet_cfg_welcome_image') - .setTitle('Set Welcome Image'); - - const imageHint = new TextDisplayBuilder() - .setContent('Provide a direct image URL **or** upload a file below. If both are given, the uploaded file takes priority. Leave the URL blank and skip the upload to remove the image.'); - - const urlLabel = new LabelBuilder() - .setLabel('Image URL (optional)') - .setTextInputComponent( - new TextInputBuilder() - .setCustomId('image_input') - .setPlaceholder('https://example.com/welcome.png') - .setStyle(TextInputStyle.Short) - .setValue(cfg.welcomeImage || '') - .setRequired(false), - ); - - const uploadLabel = new LabelBuilder() - .setLabel('Or upload an image file (optional)') - .setFileUploadComponent( - new FileUploadBuilder() - .setCustomId('image_upload') - .setRequired(false), + .setTitle('Set Welcome Image') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('image_input') + .setLabel('Image URL (leave blank to remove)') + .setStyle(TextInputStyle.Short) + .setValue(cfg.welcomeImage || '') + .setPlaceholder('https://example.com/welcome.png') + .setRequired(false), + ), ); - modal - .addTextDisplayComponents(imageHint) - .addLabelComponents(urlLabel, uploadLabel); - try { await selectInteraction.showModal(modal); } catch { @@ -488,8 +470,7 @@ async function handleWelcomeImage(selectInteraction, rootInteraction, cfg, guild if (!submitted) return; - const uploadedFiles = submitted.fields.getUploadedFiles('image_upload'); - let imageUrl = uploadedFiles?.at(0)?.url ?? submitted.fields.getTextInputValue('image_input').trim(); + let imageUrl = submitted.fields.getTextInputValue('image_input').trim(); if (imageUrl) { try { @@ -648,38 +629,23 @@ async function handleGoodbyeMessage(selectInteraction, rootInteraction, cfg, gui async function handleGoodbyeImage(selectInteraction, rootInteraction, cfg, guildId, client) { const modal = new ModalBuilder() .setCustomId('greet_cfg_goodbye_image') - .setTitle('Set Goodbye Image'); - - const imageHint = new TextDisplayBuilder() - .setContent('Provide a direct image URL **or** upload a file below. If both are given, the uploaded file takes priority. Leave the URL blank and skip the upload to remove the image.'); - - const urlLabel = new LabelBuilder() - .setLabel('Image URL (optional)') - .setTextInputComponent( - new TextInputBuilder() - .setCustomId('image_input') - .setPlaceholder('https://example.com/goodbye.png') - .setStyle(TextInputStyle.Short) - .setValue( - typeof cfg.leaveEmbed?.image === 'string' - ? cfg.leaveEmbed.image - : cfg.leaveEmbed?.image?.url || '' - ) - .setRequired(false), - ); - - const uploadLabel = new LabelBuilder() - .setLabel('Or upload an image file (optional)') - .setFileUploadComponent( - new FileUploadBuilder() - .setCustomId('image_upload') - .setRequired(false), + .setTitle('Set Goodbye Image') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('image_input') + .setLabel('Image URL (leave blank to remove)') + .setStyle(TextInputStyle.Short) + .setValue( + typeof cfg.leaveEmbed?.image === 'string' + ? cfg.leaveEmbed.image + : cfg.leaveEmbed?.image?.url || '' + ) + .setPlaceholder('https://example.com/goodbye.png') + .setRequired(false), + ), ); - modal - .addTextDisplayComponents(imageHint) - .addLabelComponents(urlLabel, uploadLabel); - try { await selectInteraction.showModal(modal); } catch { @@ -696,8 +662,7 @@ async function handleGoodbyeImage(selectInteraction, rootInteraction, cfg, guild if (!submitted) return; - const uploadedFiles = submitted.fields.getUploadedFiles('image_upload'); - let imageUrl = uploadedFiles?.at(0)?.url ?? submitted.fields.getTextInputValue('image_input').trim(); + let imageUrl = submitted.fields.getTextInputValue('image_input').trim(); if (imageUrl) { try { From 6e7b54af6d825bee4d554895b360c09cce716aef Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:55:19 -0600 Subject: [PATCH 063/115] Update reactroles.js --- src/commands/Reaction_roles/reactroles.js | 64 +++++++++++++++-------- 1 file changed, 43 insertions(+), 21 deletions(-) diff --git a/src/commands/Reaction_roles/reactroles.js b/src/commands/Reaction_roles/reactroles.js index 3361f8290..ca1093498 100644 --- a/src/commands/Reaction_roles/reactroles.js +++ b/src/commands/Reaction_roles/reactroles.js @@ -1,5 +1,5 @@ import { getColor } from '../../config/bot.js'; -import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, ActionRowBuilder, StringSelectMenuBuilder, StringSelectMenuOptionBuilder, RoleSelectMenuBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, ButtonBuilder, ButtonStyle, MessageFlags, ComponentType, EmbedBuilder, LabelBuilder, CheckboxBuilder, TextDisplayBuilder } from 'discord.js'; +import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, ActionRowBuilder, StringSelectMenuBuilder, StringSelectMenuOptionBuilder, RoleSelectMenuBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, ButtonBuilder, ButtonStyle, MessageFlags, ComponentType, EmbedBuilder } from 'discord.js'; import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; import { handleInteractionError, createError, TitanBotError, ErrorTypes, replyUserError } from '../../utils/errorHandler.js'; @@ -63,6 +63,31 @@ export default { .setDescription('Fifth role to add') .setRequired(false) ) + .addRoleOption(option => + option.setName('role6') + .setDescription('Sixth role to add') + .setRequired(false) + ) + .addRoleOption(option => + option.setName('role7') + .setDescription('Seventh role to add') + .setRequired(false) + ) + .addRoleOption(option => + option.setName('role8') + .setDescription('Eighth role to add') + .setRequired(false) + ) + .addRoleOption(option => + option.setName('role9') + .setDescription('Ninth role to add') + .setRequired(false) + ) + .addRoleOption(option => + option.setName('role10') + .setDescription('Tenth role to add') + .setRequired(false) + ) ) .addSubcommand(subcommand => subcommand @@ -226,7 +251,7 @@ async function handleSetup(interaction) { const roles = []; const roleValidationErrors = []; - for (let i = 1; i <= 5; i++) { + for (let i = 1; i <= 10; i++) { const role = interaction.options.getRole(`role${i}`); if (role) { if (role.position >= interaction.guild.members.me.roles.highest.position) { @@ -1012,22 +1037,19 @@ async function handleDeletePanel(btnInteraction, rootInteraction, panelData, pan const deleteModal = new ModalBuilder() .setCustomId('rr_delete_confirm_modal') - .setTitle('Delete Reaction Role Panel'); - - const deleteWarningText = new TextDisplayBuilder() - .setContent(`⚠️ You are about to permanently delete the panel **${title}**. This will remove the Discord message and all associated reaction role assignments.`); - - const deleteCheckbox = new CheckboxBuilder() - .setCustomId('delete_confirmation') - .setDefault(false); - - const deleteCheckboxLabel = new LabelBuilder() - .setLabel('I confirm — this cannot be undone') - .setCheckboxComponent(deleteCheckbox); - - deleteModal - .addTextDisplayComponents(deleteWarningText) - .addLabelComponents(deleteCheckboxLabel); + .setTitle('Delete Reaction Role Panel') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('delete_confirmation') + .setLabel('Type "DELETE" to confirm') + .setStyle(TextInputStyle.Short) + .setPlaceholder('DELETE') + .setMaxLength(6) + .setMinLength(6) + .setRequired(true), + ), + ); await btnInteraction.showModal(deleteModal); @@ -1043,10 +1065,10 @@ async function handleDeletePanel(btnInteraction, rootInteraction, panelData, pan return; } - const confirmed = submitted.fields.getCheckbox('delete_confirmation'); + const confirmed = submitted.fields.getTextInputValue('delete_confirmation').trim(); - if (!confirmed) { - await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'You must tick the confirmation checkbox to delete the panel.' }); + if (confirmed !== 'DELETE') { + await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'You must type "DELETE" exactly to confirm deletion.' }); await showPanelDashboard(rootInteraction, panelData, discordMsg, guildId, guild, client); return; } From 28b304f9989727d78e249aea9a690fc874c9734c Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Wed, 24 Jun 2026 14:45:10 -0600 Subject: [PATCH 064/115] Update messageCreate.js --- src/events/messageCreate.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/events/messageCreate.js b/src/events/messageCreate.js index 0f4fafd91..154e9234a 100644 --- a/src/events/messageCreate.js +++ b/src/events/messageCreate.js @@ -17,7 +17,7 @@ import { isValidCountingMessage, recordCorrectCount, } from '../services/countingGameService.js'; -import { getSticky, saveSticky } from '../commands/utility/sticky.js'; +import { getSticky, saveSticky } from '../commands/Utility/sticky.js'; import { EmbedBuilder, PermissionsBitField } from 'discord.js'; const MESSAGE_XP_RATE_LIMIT_ATTEMPTS = 12; From 751c253b1baa92ec1f9b7929b9ab563b8e371c5c Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Wed, 24 Jun 2026 19:13:37 -0600 Subject: [PATCH 065/115] Update messageCreate.js --- src/events/messageCreate.js | 120 ++++++++++++++++-------------------- 1 file changed, 53 insertions(+), 67 deletions(-) diff --git a/src/events/messageCreate.js b/src/events/messageCreate.js index 154e9234a..530f6a365 100644 --- a/src/events/messageCreate.js +++ b/src/events/messageCreate.js @@ -17,8 +17,8 @@ import { isValidCountingMessage, recordCorrectCount, } from '../services/countingGameService.js'; -import { getSticky, saveSticky } from '../commands/Utility/sticky.js'; -import { EmbedBuilder, PermissionsBitField } from 'discord.js'; +import { getSticky, saveSticky } from '../commands/utility/sticky.js'; +import { EmbedBuilder } from 'discord.js'; const MESSAGE_XP_RATE_LIMIT_ATTEMPTS = 12; const MESSAGE_XP_RATE_LIMIT_WINDOW_MS = 10000; @@ -26,112 +26,92 @@ const MESSAGE_XP_RATE_LIMIT_WINDOW_MS = 10000; export default { name: Events.MessageCreate, async execute(message, client) { - if (!message.guild) return; - if (message.author.bot) return; + try { + if (!message.guild) return; - logger.debug(`Message received from ${message.author.tag}: ${message.content}`); + // Handle sticky BEFORE bot check so non-bot messages trigger repost + // But skip if the message author is the bot itself to prevent loops + if (!message.author.bot) { + await handleSticky(message); + } - let stickyReposted = false; + if (message.author.bot) return; + + logger.debug(`Message received from ${message.author.tag}: ${message.content}`); - try { const countingProcessed = await handleCountingGame(message, client); if (countingProcessed) { return; } await handleFAQ(message); + await handlePrefixCommand(message, client); + await handleLeveling(message, client); } catch (error) { logger.error('Error in messageCreate event:', error); - } finally { - try { - stickyReposted = await handleSticky(message); - if (stickyReposted) { - logger.info(`Sticky message reposted in ${message.channel.id} after message ${message.id}`); - } - } catch (stickyError) { - logger.error('Error reposting sticky message in finally block:', stickyError); - } } } }; -// Track cooldowns per channel to avoid sticky spam -const stickyCooldowns = new Map(); -const STICKY_COOLDOWN_MS = 2 * 1000; // 2 seconds cooldown per channel +// Track active sticky reposts per channel to prevent stacking +const stickyPending = new Map(); async function handleSticky(message) { try { - logger.debug(`handleSticky triggered for channel ${message.channel.id}, message ${message.id}`); const sticky = await getSticky(message.guild.id, message.channel.id); - if (!sticky) { - logger.debug(`No sticky configured for channel ${message.channel.id}`); - return false; - } - logger.debug(`Sticky config found for channel ${message.channel.id}: ${JSON.stringify({ messageId: sticky.messageId, title: sticky.title, channelId: sticky.channelId })}`); + if (!sticky) return; - // Skip if the message sent IS the sticky itself (prevent loops) - if (message.id === sticky.messageId) { - logger.debug(`Received sticky message itself in ${message.channel.id}, skipping repost.`); - return false; - } + // Skip if the message is the sticky itself + if (message.id === sticky.messageId) return; - // Cooldown check — only repost once every few seconds per channel - const cooldownKey = `${message.guild.id}_${message.channel.id}`; - const lastRepost = stickyCooldowns.get(cooldownKey) || 0; - const now = Date.now(); + const channelKey = `${message.guild.id}_${message.channel.id}`; - if (now - lastRepost < STICKY_COOLDOWN_MS) { - logger.debug(`Sticky cooldown active for channel ${message.channel.id}`); - return false; - } - stickyCooldowns.set(cooldownKey, now); + // If a repost is already scheduled for this channel, just let it run + if (stickyPending.get(channelKey)) return; - const channelPerms = message.channel.permissionsFor(message.guild.members.me || message.client.user); - if (!channelPerms || !channelPerms.has(PermissionsBitField.Flags.SendMessages)) { - logger.warn(`Missing send permission for sticky in channel ${message.channel.id}.`); - return false; + // Mark as pending + stickyPending.set(channelKey, true); + + // Short delay so rapid messages settle before we repost + await new Promise(resolve => setTimeout(resolve, 1500)); + + // Re-fetch sticky in case it changed during the delay + const latestSticky = await getSticky(message.guild.id, message.channel.id); + if (!latestSticky) { + stickyPending.delete(channelKey); + return; } - let oldMsgDeleted = false; - if (sticky.messageId) { - const oldMsg = await message.channel.messages.fetch(sticky.messageId, { cache: false, force: true }).catch(() => null); - if (oldMsg) { - if (oldMsg.deletable) { - await oldMsg.delete().catch(error => { - logger.warn(`Failed to delete old sticky message ${sticky.messageId} in ${message.channel.id}:`, error); - }); - oldMsgDeleted = true; - } else { - logger.warn(`Old sticky message ${sticky.messageId} in ${message.channel.id} is not deletable.`); - } - } else { - logger.debug(`Old sticky message ${sticky.messageId} not found in ${message.channel.id}.`); - } + // Delete the old sticky message + if (latestSticky.messageId) { + const oldMsg = await message.channel.messages.fetch(latestSticky.messageId).catch(() => null); + if (oldMsg) await oldMsg.delete().catch(() => {}); } - // Repost the sticky at the bottom + // Repost at the bottom const embed = new EmbedBuilder() - .setTitle(sticky.title || '📌 Sticky Message') - .setDescription(sticky.message) - .setColor(parseInt(sticky.color || '0xF1C40F', 16)) + .setTitle(latestSticky.title || '📌 Sticky Message') + .setDescription(latestSticky.message) + .setColor(parseInt(latestSticky.color || '0xF1C40F', 16)) .setFooter({ text: '📌 Sticky Message' }) .setTimestamp(); const newMsg = await message.channel.send({ embeds: [embed] }); - // Update the stored message ID + // Save the new message ID await saveSticky(message.guild.id, message.channel.id, { - ...sticky, + ...latestSticky, messageId: newMsg.id, }); - logger.info(`Sticky reposted successfully in channel ${message.channel.id}`); - return true; + // Clear pending flag + stickyPending.delete(channelKey); } catch (error) { logger.error('Error handling sticky message:', error); - return false; + const channelKey = `${message.guild.id}_${message.channel.id}`; + stickyPending.delete(channelKey); } } @@ -243,7 +223,11 @@ async function handleCountingGame(message, client) { const invalidAttempt = !validCount || message.author.id === config.lastUserId; if (invalidAttempt) { + // React with ❌ before deleting so user knows why + await message.react('❌').catch(() => {}); + await new Promise(resolve => setTimeout(resolve, 600)); await message.delete().catch(() => {}); + await saveCountingGameConfig(client, message.guild.id, { ...config, nextNumber: 1, @@ -259,6 +243,8 @@ async function handleCountingGame(message, client) { return true; } + // React with ✅ for valid count + await message.react('✅').catch(() => {}); await recordCorrectCount(client, message.guild.id, message.author.id); return true; } catch (error) { From 269f655cab38c56c525fa43eed1b451229314a6e Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Wed, 24 Jun 2026 19:25:11 -0600 Subject: [PATCH 066/115] Updated backend stuff --- src/commands/Welcome/welcome.js | 167 ++++++++++++------ src/events/messageCreate.js | 6 +- src/interactions/buttons/punish_processed.js | 2 +- src/interactions/buttons/punish_roster.js | 2 +- src/interactions/buttons/punish_rosterlink.js | 2 +- 5 files changed, 118 insertions(+), 61 deletions(-) diff --git a/src/commands/Welcome/welcome.js b/src/commands/Welcome/welcome.js index 59465b401..047c9a08a 100644 --- a/src/commands/Welcome/welcome.js +++ b/src/commands/Welcome/welcome.js @@ -1,67 +1,124 @@ -import { Events, EmbedBuilder } from 'discord.js'; -import { logger } from '../utils/logger.js'; -import { getFromDb, getWelcomeConfig } from '../utils/database.js'; -import { formatWelcomeMessage } from '../utils/welcome.js'; - -const ANNOUNCEMENT_KEY = (guildId) => `announcement_config_${guildId}`; +import { getColor } from '../../config/bot.js'; +import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, EmbedBuilder, MessageFlags } from 'discord.js'; +import { getWelcomeConfig, updateWelcomeConfig } from '../../utils/database.js'; +import { formatWelcomeMessage } from '../../utils/welcome.js'; +import { logger } from '../../utils/logger.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; export default { - name: Events.GuildMemberAdd, - async execute(member, client) { - try { - // --- Primary welcome system (set via /welcome setup) --- - const welcomeConfig = await getWelcomeConfig(client, member.guild.id).catch(() => null); + data: new SlashCommandBuilder() + .setName('welcome') + .setDescription('Configure the welcome system') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) + .addSubcommand(subcommand => + subcommand + .setName('setup') + .setDescription('Set up the welcome message') + .addChannelOption(option => + option.setName('channel') + .setDescription('The channel to send welcome messages to') + .addChannelTypes(ChannelType.GuildText) + .setRequired(true)) + .addStringOption(option => + option.setName('message') + .setDescription('Welcome message. Variables: {user}, {username}, {server}, {memberCount}') + .setRequired(true)) + .addStringOption(option => + option.setName('image') + .setDescription('URL of the image to include in the welcome message') + .setRequired(false)) + .addBooleanOption(option => + option.setName('ping') + .setDescription('Whether to ping the user in the welcome message') + .setRequired(false))), - if (welcomeConfig?.enabled && welcomeConfig?.channelId) { - const welcomeChannel = member.guild.channels.cache.get(welcomeConfig.channelId) - || await member.guild.channels.fetch(welcomeConfig.channelId).catch(() => null); + async execute(interaction) { + try { + const deferSuccess = await InteractionHelper.safeDefer(interaction); + if (!deferSuccess) { + logger.warn(`Welcome interaction defer failed`, { + userId: interaction.user.id, + guildId: interaction.guildId, + commandName: 'welcome' + }); + return; + } + } catch (deferError) { + logger.error(`Welcome defer error`, { error: deferError.message }); + return; + } - if (welcomeChannel) { - const formattedMessage = formatWelcomeMessage(welcomeConfig.welcomeMessage || 'Welcome {user}!', { - user: member.user, - guild: member.guild, - }); + const { options, guild, client } = interaction; - const embed = new EmbedBuilder() - .setColor(0xFFFFFF) - .setDescription(formattedMessage) - .setFooter({ text: `${member.guild.name} Management` }) - .setTimestamp(); + if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) { + return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need the **Manage Server** permission to use `/welcome`.' }); + } - if (welcomeConfig.welcomeImage) { - embed.setImage(welcomeConfig.welcomeImage); - } + const subcommand = options.getSubcommand(); - // Title line outside the embed, pinging the user - const titleContent = `Welcome to ${member.guild.name} <@${member.id}> 🎉`; + if (subcommand === 'setup') { + const channel = options.getChannel('channel'); + const message = options.getString('message'); + const image = options.getString('image'); + const ping = options.getBoolean('ping') ?? false; - await welcomeChannel.send({ content: titleContent, embeds: [embed] }); - logger.info(`Welcome message sent for ${member.user.tag} in ${member.guild.name}`); - } - } + const existingConfig = await getWelcomeConfig(client, guild.id); + if (existingConfig?.channelId) { + logger.info(`[Welcome] Setup blocked because config already exists in channel ${existingConfig.channelId} for guild ${guild.id}`); + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `Welcome is already configured for <#${existingConfig.channelId}>. Use **/welcome config** to customize channel, message, ping, or image.` }); + } + + if (!message || message.trim().length === 0) { + logger.warn(`[Welcome] Empty message provided by ${interaction.user.tag} in ${guild.name}`); + return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Welcome message cannot be empty' }); + } - // --- Announcement system welcome (set via /announcement setchannel welcome) --- - const announcementConfig = await getFromDb(ANNOUNCEMENT_KEY(member.guild.id), {}); - if (announcementConfig?.welcomeChannelId && announcementConfig.welcomeChannelId !== welcomeConfig?.channelId) { - const announcementChannel = member.guild.channels.cache.get(announcementConfig.welcomeChannelId) - || await member.guild.channels.fetch(announcementConfig.welcomeChannelId).catch(() => null); + if (image) { + try { + new URL(image); + } catch (e) { + logger.warn(`[Welcome] Invalid image URL provided by ${interaction.user.tag}: ${image}`); + return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Please provide a valid image URL (must start with http:// or https://)' }); + } + } - if (announcementChannel) { - const embed = new EmbedBuilder() - .setColor(0xFFFFFF) - .setDescription(`Welcome to **${member.guild.name}**! We're glad to have you here.\n\nMake sure to check out the rules and grab your roles!`) - .setFooter({ text: `${member.guild.name} Management` }) - .setTimestamp(); + try { + await updateWelcomeConfig(client, guild.id, { + enabled: true, + channelId: channel.id, + welcomeMessage: message, + welcomeImage: image || undefined, + welcomePing: ping + }); - await announcementChannel.send({ - content: `Welcome to ${member.guild.name} <@${member.id}> 🎉`, - embeds: [embed], - }); - } - } + logger.info(`[Welcome] Setup configured by ${interaction.user.tag} for guild ${guild.name} (${guild.id})`); - } catch (error) { - logger.error('Error sending welcome message:', error); - } - }, -}; \ No newline at end of file + const previewMessage = formatWelcomeMessage(message, { + user: interaction.user, + guild + }); + + const embed = new EmbedBuilder() + .setColor(getColor('success')) + .setTitle('Welcome System Configured') + .setDescription(`Welcome messages will now be sent to ${channel}`) + .addFields( + { name: 'Message Preview', value: previewMessage }, + { name: 'Ping User', value: ping ? 'Yes' : 'No' }, + { name: 'Status', value: 'Enabled' } + ) + .setFooter({ text: 'Tip: Use /welcome config to customize welcome settings' }); + + if (image) { + embed.setImage(image); + } + + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); + } catch (error) { + logger.error(`[Welcome] Failed to setup welcome system for guild ${guild.id}:`, error); + await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while configuring the welcome system. Please try again.' }); + } + } + }, +}; diff --git a/src/events/messageCreate.js b/src/events/messageCreate.js index 530f6a365..81d44546f 100644 --- a/src/events/messageCreate.js +++ b/src/events/messageCreate.js @@ -1,4 +1,4 @@ -import { Events } from 'discord.js'; +import { Events } from 'discord.js'; import { logger } from '../utils/logger.js'; import { getLevelingConfig, getUserLevelData } from '../services/leveling.js'; import { addXp } from '../services/xpSystem.js'; @@ -17,7 +17,7 @@ import { isValidCountingMessage, recordCorrectCount, } from '../services/countingGameService.js'; -import { getSticky, saveSticky } from '../commands/utility/sticky.js'; +import { getSticky, saveSticky } from '../commands/Utility/sticky.js'; import { EmbedBuilder } from 'discord.js'; const MESSAGE_XP_RATE_LIMIT_ATTEMPTS = 12; @@ -321,4 +321,4 @@ async function handleLeveling(message, client) { } catch (error) { logger.error('Error handling leveling for message:', error); } -} \ No newline at end of file +} diff --git a/src/interactions/buttons/punish_processed.js b/src/interactions/buttons/punish_processed.js index 739bec2a7..7f9fbbd7d 100644 --- a/src/interactions/buttons/punish_processed.js +++ b/src/interactions/buttons/punish_processed.js @@ -1,6 +1,6 @@ // src/buttons/punish_processed.js import { EmbedBuilder } from 'discord.js'; -import { logger } from '../utils/logger.js'; +import { logger } from '../../utils/logger.js'; const BUTTON_LABELS = { punish_reviewed: '✅ Reviewed by IA/HC', diff --git a/src/interactions/buttons/punish_roster.js b/src/interactions/buttons/punish_roster.js index 0f309c4fd..2229574d8 100644 --- a/src/interactions/buttons/punish_roster.js +++ b/src/interactions/buttons/punish_roster.js @@ -1,6 +1,6 @@ // src/buttons/punish_roster.js import { EmbedBuilder } from 'discord.js'; -import { logger } from '../utils/logger.js'; +import { logger } from '../../utils/logger.js'; const BUTTON_LABELS = { punish_reviewed: '✅ Reviewed by IA/HC', diff --git a/src/interactions/buttons/punish_rosterlink.js b/src/interactions/buttons/punish_rosterlink.js index 25d07ef2b..eef07b231 100644 --- a/src/interactions/buttons/punish_rosterlink.js +++ b/src/interactions/buttons/punish_rosterlink.js @@ -1,6 +1,6 @@ // src/buttons/punish_rosterlink.js import { EmbedBuilder } from 'discord.js'; -import { logger } from '../utils/logger.js'; +import { logger } from '../../utils/logger.js'; const BUTTON_LABELS = { punish_reviewed: '✅ Reviewed by IA/HC', From 1fa2d97f55f6f57222a89c3e1df8ce017c64e13f Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Wed, 24 Jun 2026 19:34:53 -0600 Subject: [PATCH 067/115] Fixed some punish stuff --- src/interactions/buttons/punish_processed.js | 13 ++++++------- src/interactions/buttons/punish_roster.js | 13 ++++++------- src/interactions/buttons/punish_rosterlink.js | 13 ++++++------- 3 files changed, 18 insertions(+), 21 deletions(-) diff --git a/src/interactions/buttons/punish_processed.js b/src/interactions/buttons/punish_processed.js index 7f9fbbd7d..b9ec8608d 100644 --- a/src/interactions/buttons/punish_processed.js +++ b/src/interactions/buttons/punish_processed.js @@ -1,4 +1,3 @@ -// src/buttons/punish_processed.js import { EmbedBuilder } from 'discord.js'; import { logger } from '../../utils/logger.js'; @@ -9,13 +8,13 @@ const BUTTON_LABELS = { punish_rosterlink: '📋 Roster', }; -async function execute(interaction, client, args) { +async function execute(interaction, client) { try { const customId = interaction.customId; const buttonType = Object.keys(BUTTON_LABELS).find(key => customId.startsWith(key)); if (!buttonType) return; - const caseCode = customId.replace(`${buttonType}_`, ''); + const caseCode = customId.slice(buttonType.length + 1); const label = BUTTON_LABELS[buttonType]; const originalEmbed = interaction.message.embeds[0]; @@ -25,7 +24,7 @@ async function execute(interaction, client, args) { const alreadySet = (updatedEmbed.data.fields || []).some(f => f.name === label); if (alreadySet) { - await interaction.reply({ content: 'This status has already been marked.', ephemeral: true }); + await interaction.reply({ content: 'This status has already been marked.', flags: 64 }); return; } @@ -43,12 +42,12 @@ async function execute(interaction, client, args) { await interaction.message.edit({ embeds: [updatedEmbed] }); await interaction.reply({ content: `✅ Marked **${label}** for case \`${caseCode}\`.`, - ephemeral: true, + flags: 64, }); } catch (error) { logger.error('Error handling punishment button:', error); - await interaction.reply({ content: 'An error occurred.', ephemeral: true }).catch(() => {}); + await interaction.reply({ content: 'An error occurred.', flags: 64 }).catch(() => {}); } } -export default { customId: 'punish_processed', execute }; +export default { name: 'punish_processed', execute }; diff --git a/src/interactions/buttons/punish_roster.js b/src/interactions/buttons/punish_roster.js index 2229574d8..3e4753ecb 100644 --- a/src/interactions/buttons/punish_roster.js +++ b/src/interactions/buttons/punish_roster.js @@ -1,4 +1,3 @@ -// src/buttons/punish_roster.js import { EmbedBuilder } from 'discord.js'; import { logger } from '../../utils/logger.js'; @@ -9,13 +8,13 @@ const BUTTON_LABELS = { punish_rosterlink: '📋 Roster', }; -async function execute(interaction, client, args) { +async function execute(interaction, client) { try { const customId = interaction.customId; const buttonType = Object.keys(BUTTON_LABELS).find(key => customId.startsWith(key)); if (!buttonType) return; - const caseCode = customId.replace(`${buttonType}_`, ''); + const caseCode = customId.slice(buttonType.length + 1); const label = BUTTON_LABELS[buttonType]; const originalEmbed = interaction.message.embeds[0]; @@ -25,7 +24,7 @@ async function execute(interaction, client, args) { const alreadySet = (updatedEmbed.data.fields || []).some(f => f.name === label); if (alreadySet) { - await interaction.reply({ content: 'This status has already been marked.', ephemeral: true }); + await interaction.reply({ content: 'This status has already been marked.', flags: 64 }); return; } @@ -43,12 +42,12 @@ async function execute(interaction, client, args) { await interaction.message.edit({ embeds: [updatedEmbed] }); await interaction.reply({ content: `✅ Marked **${label}** for case \`${caseCode}\`.`, - ephemeral: true, + flags: 64, }); } catch (error) { logger.error('Error handling punishment button:', error); - await interaction.reply({ content: 'An error occurred.', ephemeral: true }).catch(() => {}); + await interaction.reply({ content: 'An error occurred.', flags: 64 }).catch(() => {}); } } -export default { customId: 'punish_roster', execute }; +export default { name: 'punish_roster', execute }; diff --git a/src/interactions/buttons/punish_rosterlink.js b/src/interactions/buttons/punish_rosterlink.js index eef07b231..b1944fa02 100644 --- a/src/interactions/buttons/punish_rosterlink.js +++ b/src/interactions/buttons/punish_rosterlink.js @@ -1,4 +1,3 @@ -// src/buttons/punish_rosterlink.js import { EmbedBuilder } from 'discord.js'; import { logger } from '../../utils/logger.js'; @@ -9,13 +8,13 @@ const BUTTON_LABELS = { punish_rosterlink: '📋 Roster', }; -async function execute(interaction, client, args) { +async function execute(interaction, client) { try { const customId = interaction.customId; const buttonType = Object.keys(BUTTON_LABELS).find(key => customId.startsWith(key)); if (!buttonType) return; - const caseCode = customId.replace(`${buttonType}_`, ''); + const caseCode = customId.slice(buttonType.length + 1); const label = BUTTON_LABELS[buttonType]; const originalEmbed = interaction.message.embeds[0]; @@ -25,7 +24,7 @@ async function execute(interaction, client, args) { const alreadySet = (updatedEmbed.data.fields || []).some(f => f.name === label); if (alreadySet) { - await interaction.reply({ content: 'This status has already been marked.', ephemeral: true }); + await interaction.reply({ content: 'This status has already been marked.', flags: 64 }); return; } @@ -43,12 +42,12 @@ async function execute(interaction, client, args) { await interaction.message.edit({ embeds: [updatedEmbed] }); await interaction.reply({ content: `✅ Marked **${label}** for case \`${caseCode}\`.`, - ephemeral: true, + flags: 64, }); } catch (error) { logger.error('Error handling punishment button:', error); - await interaction.reply({ content: 'An error occurred.', ephemeral: true }).catch(() => {}); + await interaction.reply({ content: 'An error occurred.', flags: 64 }).catch(() => {}); } } -export default { customId: 'punish_rosterlink', execute }; +export default { name: 'punish_rosterlink', execute }; From 6f5b8f74023e2fd6d53b033bd633eeb6e7e00596 Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Wed, 24 Jun 2026 19:39:38 -0600 Subject: [PATCH 068/115] Fixed again the punishments and Added Something to ticket side --- src/commands/Ticket/add.js | 72 ++++++++++++++++++ src/commands/Ticket/remove.js | 74 +++++++++++++++++++ src/commands/Ticket/rename.js | 66 +++++++++++++++++ src/interactions/buttons/punish_processed.js | 5 +- src/interactions/buttons/punish_roster.js | 5 +- src/interactions/buttons/punish_rosterlink.js | 5 +- 6 files changed, 215 insertions(+), 12 deletions(-) create mode 100644 src/commands/Ticket/add.js create mode 100644 src/commands/Ticket/remove.js create mode 100644 src/commands/Ticket/rename.js diff --git a/src/commands/Ticket/add.js b/src/commands/Ticket/add.js new file mode 100644 index 000000000..9971ae8c8 --- /dev/null +++ b/src/commands/Ticket/add.js @@ -0,0 +1,72 @@ +import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; +import { successEmbed } from '../../utils/embeds.js'; +import { logger } from '../../utils/logger.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { handleInteractionError, replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; +import { getTicketPermissionContext } from '../../utils/ticketPermissions.js'; + +export default { + data: new SlashCommandBuilder() + .setName('add') + .setDescription('Add a user to the current ticket') + .addUserOption(opt => + opt.setName('user') + .setDescription('The user to add to this ticket') + .setRequired(true) + ) + .setDMPermission(false), + category: 'ticket', + + async execute(interaction, config, client) { + try { + const deferred = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); + if (!deferred) return; + + const permissionContext = await getTicketPermissionContext({ client, interaction }); + if (!permissionContext.ticketData) { + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'This command can only be used in a valid ticket channel.' }); + } + + if (!permissionContext.canManageTicket) { + return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need the Ticket Staff Role to add users to tickets.' }); + } + + const user = interaction.options.getUser('user'); + + if (user.bot) { + return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'You cannot add bots to tickets.' }); + } + + // Check if user already has access + const existingPerms = interaction.channel.permissionOverwrites.cache.get(user.id); + if (existingPerms?.allow.has(PermissionFlagsBits.ViewChannel)) { + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `<@${user.id}> already has access to this ticket.` }); + } + + await interaction.channel.permissionOverwrites.edit(user.id, { + ViewChannel: true, + SendMessages: true, + ReadMessageHistory: true, + AttachFiles: true, + }); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [successEmbed('✅ User Added', `<@${user.id}> has been added to this ticket.`)], + }); + + await interaction.channel.send({ + embeds: [successEmbed('👤 User Added', `<@${user.id}> was added to this ticket by <@${interaction.user.id}>.`)], + }); + + logger.info('User added to ticket', { + userId: interaction.user.id, + addedUserId: user.id, + channelId: interaction.channel.id, + guildId: interaction.guildId, + }); + } catch (error) { + logger.error('Add command error:', error); + await handleInteractionError(interaction, error, { subtype: 'add_failed' }); + } + }, +}; diff --git a/src/commands/Ticket/remove.js b/src/commands/Ticket/remove.js new file mode 100644 index 000000000..3884af5a4 --- /dev/null +++ b/src/commands/Ticket/remove.js @@ -0,0 +1,74 @@ +import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; +import { successEmbed } from '../../utils/embeds.js'; +import { logger } from '../../utils/logger.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { handleInteractionError, replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; +import { getTicketPermissionContext } from '../../utils/ticketPermissions.js'; + +export default { + data: new SlashCommandBuilder() + .setName('remove') + .setDescription('Remove a user from the current ticket') + .addUserOption(opt => + opt.setName('user') + .setDescription('The user to remove from this ticket') + .setRequired(true) + ) + .setDMPermission(false), + category: 'ticket', + + async execute(interaction, config, client) { + try { + const deferred = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); + if (!deferred) return; + + const permissionContext = await getTicketPermissionContext({ client, interaction }); + if (!permissionContext.ticketData) { + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'This command can only be used in a valid ticket channel.' }); + } + + if (!permissionContext.canManageTicket) { + return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need the Ticket Staff Role to remove users from tickets.' }); + } + + const user = interaction.options.getUser('user'); + + // Prevent removing the ticket creator + const ticketCreatorId = permissionContext.ticketData?.userId; + if (user.id === ticketCreatorId) { + return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'You cannot remove the ticket creator from their own ticket.' }); + } + + // Prevent removing yourself + if (user.id === interaction.user.id) { + return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'You cannot remove yourself from a ticket.' }); + } + + // Check if user actually has access + const existingPerms = interaction.channel.permissionOverwrites.cache.get(user.id); + if (!existingPerms) { + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `<@${user.id}> doesn't have explicit access to this ticket.` }); + } + + await interaction.channel.permissionOverwrites.delete(user.id); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [successEmbed('✅ User Removed', `<@${user.id}> has been removed from this ticket.`)], + }); + + await interaction.channel.send({ + embeds: [successEmbed('👤 User Removed', `<@${user.id}> was removed from this ticket by <@${interaction.user.id}>.`)], + }); + + logger.info('User removed from ticket', { + userId: interaction.user.id, + removedUserId: user.id, + channelId: interaction.channel.id, + guildId: interaction.guildId, + }); + } catch (error) { + logger.error('Remove command error:', error); + await handleInteractionError(interaction, error, { subtype: 'remove_failed' }); + } + }, +}; diff --git a/src/commands/Ticket/rename.js b/src/commands/Ticket/rename.js new file mode 100644 index 000000000..7dda718eb --- /dev/null +++ b/src/commands/Ticket/rename.js @@ -0,0 +1,66 @@ +import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; +import { successEmbed } from '../../utils/embeds.js'; +import { logger } from '../../utils/logger.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { handleInteractionError, replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; +import { getTicketPermissionContext } from '../../utils/ticketPermissions.js'; + +export default { + data: new SlashCommandBuilder() + .setName('rename') + .setDescription('Rename the current ticket channel') + .addStringOption(opt => + opt.setName('name') + .setDescription('New name for the ticket channel') + .setRequired(true) + ) + .setDMPermission(false), + category: 'ticket', + + async execute(interaction, config, client) { + try { + const deferred = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); + if (!deferred) return; + + const permissionContext = await getTicketPermissionContext({ client, interaction }); + if (!permissionContext.ticketData) { + return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'This command can only be used in a valid ticket channel.' }); + } + + if (!permissionContext.canManageTicket) { + return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need the Ticket Staff Role to rename tickets.' }); + } + + const newName = interaction.options.getString('name') + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/[^a-z0-9-]/g, ''); + + if (!newName) { + return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Invalid channel name. Use letters, numbers, and hyphens only.' }); + } + + const oldName = interaction.channel.name; + await interaction.channel.setName(newName); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [successEmbed('✅ Ticket Renamed', `Channel renamed from \`${oldName}\` to \`${newName}\`.`)], + }); + + await interaction.channel.send({ + embeds: [successEmbed('📝 Ticket Renamed', `This ticket was renamed to \`${newName}\` by <@${interaction.user.id}>.`)], + }); + + logger.info('Ticket renamed', { + userId: interaction.user.id, + channelId: interaction.channel.id, + oldName, + newName, + guildId: interaction.guildId, + }); + } catch (error) { + logger.error('Rename command error:', error); + await handleInteractionError(interaction, error, { subtype: 'rename_failed' }); + } + }, +}; diff --git a/src/interactions/buttons/punish_processed.js b/src/interactions/buttons/punish_processed.js index b9ec8608d..94679cfa1 100644 --- a/src/interactions/buttons/punish_processed.js +++ b/src/interactions/buttons/punish_processed.js @@ -2,10 +2,7 @@ import { EmbedBuilder } from 'discord.js'; import { logger } from '../../utils/logger.js'; const BUTTON_LABELS = { - punish_reviewed: '✅ Reviewed by IA/HC', - punish_processed: '🏢 Department Hub Processed', - punish_roster: '🔄 Roles & Roster Updated', - punish_rosterlink: '📋 Roster', + punish_reviewed: '✅ Reviewed Management', }; async function execute(interaction, client) { diff --git a/src/interactions/buttons/punish_roster.js b/src/interactions/buttons/punish_roster.js index 3e4753ecb..d5ce028ff 100644 --- a/src/interactions/buttons/punish_roster.js +++ b/src/interactions/buttons/punish_roster.js @@ -2,10 +2,7 @@ import { EmbedBuilder } from 'discord.js'; import { logger } from '../../utils/logger.js'; const BUTTON_LABELS = { - punish_reviewed: '✅ Reviewed by IA/HC', - punish_processed: '🏢 Department Hub Processed', - punish_roster: '🔄 Roles & Roster Updated', - punish_rosterlink: '📋 Roster', + punish_reviewed: '✅ Reviewed by Management', }; async function execute(interaction, client) { diff --git a/src/interactions/buttons/punish_rosterlink.js b/src/interactions/buttons/punish_rosterlink.js index b1944fa02..f57955c85 100644 --- a/src/interactions/buttons/punish_rosterlink.js +++ b/src/interactions/buttons/punish_rosterlink.js @@ -2,10 +2,7 @@ import { EmbedBuilder } from 'discord.js'; import { logger } from '../../utils/logger.js'; const BUTTON_LABELS = { - punish_reviewed: '✅ Reviewed by IA/HC', - punish_processed: '🏢 Department Hub Processed', - punish_roster: '🔄 Roles & Roster Updated', - punish_rosterlink: '📋 Roster', + punish_reviewed: '✅ Reviewed by Management', }; async function execute(interaction, client) { From 73ddf748511a0262a32f9dc7c939ed3270ab5bb5 Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Wed, 24 Jun 2026 19:57:49 -0600 Subject: [PATCH 069/115] Added Mass DM --- src/commands/admin/massdm.js | 195 +++++++++++++++++++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 src/commands/admin/massdm.js diff --git a/src/commands/admin/massdm.js b/src/commands/admin/massdm.js new file mode 100644 index 000000000..63cef0558 --- /dev/null +++ b/src/commands/admin/massdm.js @@ -0,0 +1,195 @@ +import { + SlashCommandBuilder, + EmbedBuilder, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + MessageFlags, + ComponentType, +} from 'discord.js'; +import { successEmbed } from '../../utils/embeds.js'; +import { logger } from '../../utils/logger.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; + +const ALLOWED_USER_IDS = ['710198142934712421', '851642953176842260']; + +export default { + data: new SlashCommandBuilder() + .setName('massdm') + .setDescription('Send a DM to all server members') + .addStringOption(opt => + opt.setName('title') + .setDescription('Title of the DM message') + .setRequired(true) + ) + .addStringOption(opt => + opt.setName('message') + .setDescription('The message to send to everyone') + .setRequired(true) + ) + .addStringOption(opt => + opt.setName('color') + .setDescription('Embed color') + .setRequired(false) + .addChoices( + { name: 'Blue (default)', value: '0x3498DB' }, + { name: 'Green', value: '0x2ECC71' }, + { name: 'Red', value: '0xE74C3C' }, + { name: 'Gold', value: '0xF1C40F' }, + { name: 'Purple', value: '0x9B59B6' }, + { name: 'White', value: '0xFFFFFF' }, + ) + ), + + category: 'admin', + + async execute(interaction, config, client) { + try { + // Check if user is allowed + if (!ALLOWED_USER_IDS.includes(interaction.user.id)) { + return interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(0xE74C3C) + .setDescription('❌ You do not have permission to use this command.'), + ], + flags: MessageFlags.Ephemeral, + }); + } + + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + const title = interaction.options.getString('title'); + const message = interaction.options.getString('message'); + const colorStr = interaction.options.getString('color') || '0x3498DB'; + + // Fetch all members + await interaction.guild.members.fetch(); + const members = interaction.guild.members.cache.filter(m => !m.user.bot); + const totalCount = members.size; + + // Show confirmation + const confirmEmbed = new EmbedBuilder() + .setColor(0xF39C12) + .setTitle('⚠️ Confirm Mass DM') + .setDescription(`You are about to DM **${totalCount} members**. This cannot be undone.`) + .addFields( + { name: 'Title', value: title, inline: false }, + { name: 'Message', value: message.length > 200 ? message.slice(0, 200) + '...' : message, inline: false }, + ) + .setFooter({ text: 'Click Confirm to send or Cancel to abort.' }) + .setTimestamp(); + + const buttons = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('massdm_confirm') + .setLabel('✅ Confirm Send') + .setStyle(ButtonStyle.Success), + new ButtonBuilder() + .setCustomId('massdm_cancel') + .setLabel('❌ Cancel') + .setStyle(ButtonStyle.Danger), + ); + + await InteractionHelper.universalReply(interaction, { + embeds: [confirmEmbed], + components: [buttons], + }); + + // Wait for button click + const collector = interaction.channel.createMessageComponentCollector({ + componentType: ComponentType.Button, + filter: i => i.user.id === interaction.user.id && ['massdm_confirm', 'massdm_cancel'].includes(i.customId), + time: 30000, + max: 1, + }); + + collector.on('collect', async btnInteraction => { + await btnInteraction.deferUpdate(); + + if (btnInteraction.customId === 'massdm_cancel') { + await InteractionHelper.universalReply(interaction, { + embeds: [new EmbedBuilder().setColor(0xE74C3C).setDescription('❌ Mass DM cancelled.')], + components: [], + }); + return; + } + + // Build the DM embed + const dmEmbed = new EmbedBuilder() + .setTitle(`📢 ${title}`) + .setDescription(message) + .setColor(parseInt(colorStr, 16)) + .setFooter({ text: `From ${interaction.guild.name}`, iconURL: interaction.guild.iconURL() }) + .setTimestamp(); + + // Update to show sending progress + await InteractionHelper.universalReply(interaction, { + embeds: [ + new EmbedBuilder() + .setColor(0x3498DB) + .setTitle('📨 Sending DMs...') + .setDescription(`Sending to **${totalCount}** members. This may take a while...`), + ], + components: [], + }); + + // Send DMs + let sent = 0; + let failed = 0; + + for (const [, member] of members) { + try { + await member.send({ embeds: [dmEmbed] }); + sent++; + } catch { + failed++; + } + + // Small delay to avoid rate limits + if ((sent + failed) % 10 === 0) { + await new Promise(resolve => setTimeout(resolve, 1000)); + } + } + + // Final report + await InteractionHelper.universalReply(interaction, { + embeds: [ + new EmbedBuilder() + .setColor(0x2ECC71) + .setTitle('✅ Mass DM Complete') + .addFields( + { name: 'Total Members', value: `${totalCount}`, inline: true }, + { name: 'Successfully Sent', value: `${sent}`, inline: true }, + { name: 'Failed (DMs closed)', value: `${failed}`, inline: true }, + ) + .setTimestamp(), + ], + components: [], + }); + + logger.info('Mass DM completed', { + userId: interaction.user.id, + guildId: interaction.guildId, + sent, + failed, + total: totalCount, + }); + }); + + collector.on('end', (collected, reason) => { + if (reason === 'time' && collected.size === 0) { + InteractionHelper.universalReply(interaction, { + embeds: [new EmbedBuilder().setColor(0xE74C3C).setDescription('❌ Mass DM timed out. No messages were sent.')], + components: [], + }).catch(() => {}); + } + }); + + } catch (error) { + logger.error('Mass DM command error:', error); + await handleInteractionError(interaction, error, { subtype: 'massdm_failed' }); + } + }, +}; From 1458971e7a881571279154db4afb60f9555b360f Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:08:34 -0600 Subject: [PATCH 070/115] Removed some stuff we dont need --- src/commands/Economy/balance.js | 87 --- src/commands/Economy/beg.js | 99 ---- src/commands/Economy/buy.js | 157 ----- src/commands/Economy/crime.js | 119 ---- src/commands/Economy/daily.js | 103 ---- src/commands/Economy/deposit.js | 138 ----- src/commands/Economy/economy.js | 32 -- src/commands/Economy/eleaderboard.js | 88 --- src/commands/Economy/fish.js | 132 ----- src/commands/Economy/gamble.js | 130 ----- src/commands/Economy/inventory.js | 70 --- src/commands/Economy/mine.js | 93 --- .../Economy/modules/economy_dashboard.js | 536 ------------------ src/commands/Economy/modules/shop_browse.js | 90 --- .../Economy/modules/shop_config_setrole.js | 30 - src/commands/Economy/pay.js | 149 ----- src/commands/Economy/rob.js | 153 ----- src/commands/Economy/shop-config.js | 28 - src/commands/Economy/shop.js | 13 - src/commands/Economy/slut.js | 188 ------ src/commands/Economy/withdraw.js | 85 --- src/commands/Economy/work.js | 122 ---- src/commands/Fun/fight.js | 92 --- src/commands/Fun/flip.js | 33 -- src/commands/Fun/roll.js | 88 --- src/commands/Search/define.js | 104 ---- src/commands/Search/google.js | 50 -- src/commands/Search/urban.js | 131 ----- src/commands/Tools/calculate.js | 333 ----------- src/commands/Tools/countdown.js | 104 ---- src/commands/Utility/weather.js | 137 ----- 31 files changed, 3714 deletions(-) delete mode 100644 src/commands/Economy/balance.js delete mode 100644 src/commands/Economy/beg.js delete mode 100644 src/commands/Economy/buy.js delete mode 100644 src/commands/Economy/crime.js delete mode 100644 src/commands/Economy/daily.js delete mode 100644 src/commands/Economy/deposit.js delete mode 100644 src/commands/Economy/economy.js delete mode 100644 src/commands/Economy/eleaderboard.js delete mode 100644 src/commands/Economy/fish.js delete mode 100644 src/commands/Economy/gamble.js delete mode 100644 src/commands/Economy/inventory.js delete mode 100644 src/commands/Economy/mine.js delete mode 100644 src/commands/Economy/modules/economy_dashboard.js delete mode 100644 src/commands/Economy/modules/shop_browse.js delete mode 100644 src/commands/Economy/modules/shop_config_setrole.js delete mode 100644 src/commands/Economy/pay.js delete mode 100644 src/commands/Economy/rob.js delete mode 100644 src/commands/Economy/shop-config.js delete mode 100644 src/commands/Economy/shop.js delete mode 100644 src/commands/Economy/slut.js delete mode 100644 src/commands/Economy/withdraw.js delete mode 100644 src/commands/Economy/work.js delete mode 100644 src/commands/Fun/fight.js delete mode 100644 src/commands/Fun/flip.js delete mode 100644 src/commands/Fun/roll.js delete mode 100644 src/commands/Search/define.js delete mode 100644 src/commands/Search/google.js delete mode 100644 src/commands/Search/urban.js delete mode 100644 src/commands/Tools/calculate.js delete mode 100644 src/commands/Tools/countdown.js delete mode 100644 src/commands/Utility/weather.js diff --git a/src/commands/Economy/balance.js b/src/commands/Economy/balance.js deleted file mode 100644 index 95528ecd9..000000000 --- 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 e2b37b0db..000000000 --- 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 = 50; -const MAX_WIN = 200; -const SUCCESS_CHANCE = 0.7; - -export default { - data: new SlashCommandBuilder() - .setName('beg') - .setDescription('Beg for a small amount of money'), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userId = interaction.user.id; - const guildId = interaction.guildId; - - let userData = await getEconomyData(client, guildId, userId); - - if (!userData) { - throw createError( - "Failed to load economy data", - ErrorTypes.DATABASE, - "Failed to load your economy data. Please try again later.", - { userId, guildId } - ); - } - - const lastBeg = userData.lastBeg || 0; - const remainingTime = lastBeg + COOLDOWN - Date.now(); - - if (remainingTime > 0) { - const minutes = Math.floor(remainingTime / 60000); - const seconds = Math.floor((remainingTime % 60000) / 1000); - - let timeMessage = - minutes > 0 ? `${minutes} minute(s)` : `${seconds} second(s)`; - - throw createError( - "Beg cooldown active", - ErrorTypes.RATE_LIMIT, - `You are tired from begging! Try again in **${timeMessage}**.`, - { remainingTime, minutes, seconds, cooldownType: 'beg' } - ); - } - - const success = Math.random() < SUCCESS_CHANCE; - - let replyEmbed; - let newCash = userData.wallet; - - if (success) { - const amountWon = - Math.floor(Math.random() * (MAX_WIN - MIN_WIN + 1)) + MIN_WIN; - - newCash += amountWon; - - const successMessages = [ - `A kind stranger drops **$${amountWon.toLocaleString()}** into your cup.`, - `You spotted an unattended wallet! You grab **$${amountWon.toLocaleString()}** and run.`, - `Someone took pity on you and gave you **$${amountWon.toLocaleString()}**!`, - `You found **$${amountWon.toLocaleString()}** under a park bench.`, - ]; - - replyEmbed = 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 55a3d121f..000000000 --- a/src/commands/Economy/buy.js +++ /dev/null @@ -1,157 +0,0 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { shopItems } from '../../config/shop/items.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import { getGuildConfig } from '../../services/guildConfig.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -const SHOP_ITEMS = shopItems; - -export default { - data: new SlashCommandBuilder() - .setName('buy') - .setDescription('Buy an item from the shop') - .addStringOption(option => - option - .setName('item_id') - .setDescription('ID of the item to buy') - .setRequired(true) - ) - .addIntegerOption(option => - option - .setName('quantity') - .setDescription('Quantity to buy (default: 1)') - .setRequired(false) - .setMinValue(1) - .setMaxValue(10) - ), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userId = interaction.user.id; - const guildId = interaction.guildId; - const itemId = interaction.options.getString("item_id").toLowerCase(); - const quantity = interaction.options.getInteger("quantity") || 1; - - const item = SHOP_ITEMS.find(i => i.id === itemId); - - if (!item) { - throw createError( - `Item ${itemId} not found`, - ErrorTypes.VALIDATION, - `The item ID \`${itemId}\` does not exist in the shop.`, - { itemId } - ); - } - - if (quantity < 1) { - throw createError( - "Invalid quantity", - ErrorTypes.VALIDATION, - "You must purchase a quantity of 1 or more.", - { quantity } - ); - } - - const totalCost = item.price * quantity; - - const guildConfig = await getGuildConfig(client, guildId); - const PREMIUM_ROLE_ID = guildConfig.premiumRoleId; - - const userData = await getEconomyData(client, guildId, userId); - - if (userData.wallet < totalCost) { - throw createError( - "Insufficient funds", - ErrorTypes.VALIDATION, - `You need **$${totalCost.toLocaleString()}** to purchase ${quantity}x **${item.name}**, but you only have **$${userData.wallet.toLocaleString()}** in cash.`, - { required: totalCost, current: userData.wallet, itemId, quantity } - ); - } - - if (item.type === "role" && itemId === "premium_role") { - if (!PREMIUM_ROLE_ID) { - throw createError( - "Premium role not configured", - ErrorTypes.CONFIGURATION, - "The **Premium Shop Role** has not been configured by a server administrator yet.", - { itemId } - ); - } - if (interaction.member.roles.cache.has(PREMIUM_ROLE_ID)) { - throw createError( - "Role already owned", - ErrorTypes.VALIDATION, - `You already have the **${item.name}** role.`, - { itemId, roleId: PREMIUM_ROLE_ID } - ); - } - if (quantity > 1) { - throw createError( - "Invalid quantity for role", - ErrorTypes.VALIDATION, - `You can only purchase the **${item.name}** role once.`, - { itemId, quantity } - ); - } - } - - userData.wallet -= totalCost; - - let successDescription = `You successfully purchased ${quantity}x **${item.name}** for **$${totalCost.toLocaleString()}**!`; - - if (item.type === "role" && itemId === "premium_role") { - const member = interaction.member; - - const role = interaction.guild.roles.cache.get(PREMIUM_ROLE_ID); - - if (!role) { - throw createError( - "Role not found", - ErrorTypes.CONFIGURATION, - "The configured premium role no longer exists in this guild.", - { roleId: PREMIUM_ROLE_ID } - ); - } - - try { - await member.roles.add( - role, - `Purchased role: ${item.name}`, - ); - successDescription += `\n\n**👑 The role ${role.toString()} has been granted to you!**`; - } catch (roleError) { - userData.wallet += totalCost; - await setEconomyData(client, guildId, userId, userData); - throw createError( - "Role assignment failed", - ErrorTypes.DISCORD_API, - "Successfully deducted money, but failed to grant the role. Your cash has been refunded.", - { roleId: PREMIUM_ROLE_ID, originalError: roleError.message } - ); - } - } else if (item.type === "upgrade") { - userData.upgrades[itemId] = true; - successDescription += `\n\n**✨ Your upgrade is now active!**`; - } else if (item.type === "consumable") { - userData.inventory[itemId] = - (userData.inventory[itemId] || 0) + quantity; - } - - await setEconomyData(client, guildId, userId, userData); - - const embed = successEmbed( - "💰 Purchase Successful", - successDescription, - ).addFields({ - name: "New Balance", - value: `$${userData.wallet.toLocaleString()}`, - inline: true, - }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed], flags: [MessageFlags.Ephemeral] }); - }, { command: 'buy' }) -}; \ 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 f342e0961..000000000 --- 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 = 1 * 1 * 1000; -const MIN_CRIME_AMOUNT = 100; -const MAX_CRIME_AMOUNT = 2000; -const FAILURE_RATE = 0.4; -const JAIL_TIME = 1 * 1 * 1 * 1000; - -const CRIME_TYPES = [ - { name: "Pickpocketing", min: 1, max: 1, risk: 0.3 }, - { name: "Burglary", min: 1, max: 1, risk: 0.4 }, - { name: "Bank Heist", min: 1, max: 1, risk: 0.6 }, - { name: "Art Theft", min: 1, max: 1, risk: 0.7 }, - { name: "Cybercrime", min: 1, max: 1, risk: 0.8 }, -]; - -export default { - data: new SlashCommandBuilder() - .setName('crime') - .setDescription('Commit a crime to earn money (risky)') - .addStringOption(option => - option - .setName('type') - .setDescription('Type of crime to commit') - .setRequired(true) - .addChoices( - { name: 'Pickpocketing', value: 'pickpocketing' }, - { name: 'Burglary', value: 'burglary' }, - { name: 'Bank Heist', value: 'bank-heist' }, - { name: 'Art Theft', value: 'art-theft' }, - { name: 'Cybercrime', value: 'cybercrime' }, - ) - ), - - execute: withErrorHandling(async (interaction, config, client) => { - await InteractionHelper.safeDefer(interaction); - - const userId = interaction.user.id; - const guildId = interaction.guildId; - const now = Date.now(); - - const userData = await getEconomyData(client, guildId, userId); - const lastCrime = userData.cooldowns?.crime || 0; - const isJailed = userData.jailedUntil && userData.jailedUntil > now; - - if (isJailed) { - const timeLeft = Math.ceil((userData.jailedUntil - now) / (1000 * 60)); - throw createError( - "User is in jail", - ErrorTypes.RATE_LIMIT, - `You're in jail for ${timeLeft} more minutes!`, - { jailTimeRemaining: userData.jailedUntil - now } - ); - } - - if (now < lastCrime + CRIME_COOLDOWN) { - const timeLeft = Math.ceil((lastCrime + CRIME_COOLDOWN - now) / (1000 * 60)); - throw createError( - "Crime cooldown active", - ErrorTypes.RATE_LIMIT, - `You need to wait ${timeLeft} more minutes before committing another crime.`, - { remaining: lastCrime + CRIME_COOLDOWN - now, cooldownType: 'crime' } - ); - } - - const crimeType = interaction.options.getString("type").toLowerCase(); - const crime = CRIME_TYPES.find( - c => c.name.toLowerCase().replace(/\s+/g, '-') === crimeType - ); - - if (!crime) { - throw createError( - "Invalid crime type", - ErrorTypes.VALIDATION, - "Please select a valid crime type.", - { crimeType } - ); - } - - const isSuccess = Math.random() > crime.risk; - const amountEarned = isSuccess - ? Math.floor(Math.random() * (crime.max - crime.min + 1)) + crime.min - : 0; - - userData.cooldowns = userData.cooldowns || {}; - userData.cooldowns.crime = now; - - if (isSuccess) { - userData.wallet = (userData.wallet || 0) + amountEarned; - - await setEconomyData(client, guildId, userId, userData); - - const embed = successEmbed( - "🕵️ Crime Successful!", - `You successfully committed ${crime.name} and earned **${amountEarned}** coins!` - ); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - } else { - const fine = Math.floor(amountEarned * 0.2); - userData.wallet = Math.max(0, (userData.wallet || 0) - fine); - userData.jailedUntil = now + JAIL_TIME; - - await setEconomyData(client, guildId, userId, userData); - - const embed = warningEmbed( - "🚔 Crime Failed!", - `You were caught while attempting ${crime.name} and have been sent to jail!` + - `You were fined ${fine} coins and will be in jail for 2 hours.` - ); - - 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 06cd82ad6..000000000 --- a/src/commands/Economy/daily.js +++ /dev/null @@ -1,103 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import { getGuildConfig } from '../../services/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'; - -const DAILY_COOLDOWN = 24 * 60 * 60 * 1000; -const DAILY_AMOUNT = 1000; -const PREMIUM_BONUS_PERCENTAGE = 0.1; - -export default { - data: new SlashCommandBuilder() - .setName('daily') - .setDescription('Claim your daily cash reward'), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userId = interaction.user.id; - const guildId = interaction.guildId; - const now = Date.now(); - - logger.debug(`[ECONOMY] Daily claimed started for ${userId}`, { userId, guildId }); - - const userData = await getEconomyData(client, guildId, userId); - - if (!userData) { - throw createError( - "Failed to load economy data for daily", - ErrorTypes.DATABASE, - "Failed to load your economy data. Please try again later.", - { userId, guildId } - ); - } - - const lastDaily = userData.lastDaily || 0; - - if (now < lastDaily + DAILY_COOLDOWN) { - const timeRemaining = lastDaily + DAILY_COOLDOWN - now; - throw createError( - "Daily cooldown active", - ErrorTypes.RATE_LIMIT, - `You need to wait before claiming daily again. Try again in **${formatDuration(timeRemaining)}**.`, - { timeRemaining, cooldownType: 'daily' } - ); - } - - const guildConfig = await getGuildConfig(client, guildId); - const PREMIUM_ROLE_ID = guildConfig.premiumRoleId; - - let earned = DAILY_AMOUNT; - let bonusMessage = ""; - let hasPremiumRole = false; - - if ( - PREMIUM_ROLE_ID && - interaction.member && - interaction.member.roles.cache.has(PREMIUM_ROLE_ID) - ) { - const bonusAmount = Math.floor( - DAILY_AMOUNT * PREMIUM_BONUS_PERCENTAGE, - ); - earned += bonusAmount; - bonusMessage = `\n✨ **Premium Bonus:** +$${bonusAmount.toLocaleString()}`; - hasPremiumRole = true; - } - - userData.wallet = (userData.wallet || 0) + earned; - userData.lastDaily = now; - - await setEconomyData(client, guildId, userId, userData); - - logger.info(`[ECONOMY_TRANSACTION] Daily claimed`, { - userId, - guildId, - amount: earned, - newWallet: userData.wallet, - hasPremium: hasPremiumRole, - timestamp: new Date().toISOString() - }); - - const embed = successEmbed( - "✅ Daily Claimed!", - `You have claimed your daily **$${earned.toLocaleString()}**!${bonusMessage}` - ) - .addFields({ - name: "New Cash Balance", - value: `$${userData.wallet.toLocaleString()}`, - inline: true, - }) - .setFooter({ - text: hasPremiumRole - ? `Next claim in 24 hours. (Premium Active)` - : `Next claim in 24 hours.`, - }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - }, { command: 'daily' }) -}; \ 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 56015b5fb..000000000 --- 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 d702eb615..000000000 --- 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 76a430c1b..000000000 --- a/src/commands/Economy/eleaderboard.js +++ /dev/null @@ -1,88 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed } from '../../utils/embeds.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -export default { - data: new SlashCommandBuilder() - .setName("eleaderboard") - .setDescription("View the server's top 10 richest users.") - .setDMPermission(false), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const guildId = interaction.guildId; - - logger.debug(`[ECONOMY] Leaderboard requested`, { guildId }); - - const prefix = `economy:${guildId}:`; - - let allKeys = await client.db.list(prefix); - - if (!Array.isArray(allKeys)) { - allKeys = []; - } - - if (allKeys.length === 0) { - throw createError( - "No economy data found", - ErrorTypes.VALIDATION, - "No economy data found for this server." - ); - } - - let allUserData = []; - - for (const key of allKeys) { - const userId = key.replace(prefix, ""); - const userData = await client.db.get(key); - - if (userData) { - allUserData.push({ - userId: userId, - net_worth: (userData.wallet || 0) + (userData.bank || 0), - }); - } - } - - allUserData.sort((a, b) => b.net_worth - a.net_worth); - - const topUsers = allUserData.slice(0, 10); - const userRank = - allUserData.findIndex((u) => u.userId === interaction.user.id) + - 1; - const rankEmoji = ["🥇", "🥈", "🥉"]; - const leaderboardEntries = []; - - for (let i = 0; i < topUsers.length; i++) { - const user = topUsers[i]; - const rank = i + 1; - const emoji = rankEmoji[i] || `**#${rank}**`; - - leaderboardEntries.push( - `${emoji} <@${user.userId}> - 🏦 ${user.net_worth.toLocaleString()}`, - ); - } - - logger.info(`[ECONOMY] Leaderboard generated`, { - guildId, - userCount: allUserData.length, - userRank - }); - - const description = leaderboardEntries.length > 0 - ? leaderboardEntries.join("\n") - : "No economy data is available for this server yet."; - - const embed = createEmbed({ - title: `Economy Leaderboard`, - description, - footer: `Your Rank: ${userRank > 0 ?`#${userRank}`: "No ranking data available"}`, - }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - }, { command: 'eleaderboard' }) -}; \ 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 5288f048b..000000000 --- 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 ee9837dba..000000000 --- a/src/commands/Economy/gamble.js +++ /dev/null @@ -1,130 +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); -cashChange = amountWon; - - 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 b7efcfec8..000000000 --- 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 fda8ba1dd..000000000 --- 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 2bb7d74b7..000000000 --- a/src/commands/Economy/modules/economy_dashboard.js +++ /dev/null @@ -1,536 +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 { 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(`economy:${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 result = await addMoney(client, guild.id, userId, amount, type); - - if (!result.success) { - await replyUserError(submitted, { type: ErrorTypes.UNKNOWN, message: 'result.error || \'An error occurred.\'' }); - return; - } - - const currencySymbol = BotConfig.economy.currency.symbol; - - await submitted.reply({ - embeds: [successEmbed('Currency Added', `Successfully added ${currencySymbol}${amount.toLocaleString()} to ${member.user.tag}'s ${type}.\n**New Balance:** ${currencySymbol}${result.newBalance.toLocaleString()}`)], - flags: MessageFlags.Ephemeral, - }); - - logger.info(`[ECONOMY_DASHBOARD] Currency added`, { - adminId: submitted.user.id, - targetUserId: userId, - amount, - type, - newBalance: result.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 result = await removeMoney(client, guild.id, userId, amount, type); - - if (!result.success) { - await replyUserError(submitted, { type: ErrorTypes.UNKNOWN, message: 'result.error || \'An error occurred.\'' }); - return; - } - - const currencySymbol = BotConfig.economy.currency.symbol; - - await submitted.reply({ - embeds: [successEmbed('Currency Removed', `Successfully removed ${currencySymbol}${amount.toLocaleString()} from ${member.user.tag}'s ${type}.\n**New Balance:** ${currencySymbol}${result.newBalance.toLocaleString()}`)], - flags: MessageFlags.Ephemeral, - }); - - logger.info(`[ECONOMY_DASHBOARD] Currency removed`, { - adminId: submitted.user.id, - targetUserId: userId, - amount, - type, - newBalance: result.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 0313ddb24..000000000 --- a/src/commands/Economy/modules/shop_browse.js +++ /dev/null @@ -1,90 +0,0 @@ -import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ComponentType, EmbedBuilder, MessageFlags } from 'discord.js'; -import { shopItems } from '../../../config/shop/items.js'; -import { getColor } from '../../../config/bot.js'; -import { logger } from '../../../utils/logger.js'; - -export default { - async execute(interaction, config, client) { - try { - const TARGET_MAX_PAGES = 3; - const ITEMS_PER_PAGE = Math.max(1, Math.ceil(shopItems.length / TARGET_MAX_PAGES)); - const totalPages = Math.ceil(shopItems.length / ITEMS_PER_PAGE); - let currentPage = 1; - - const createShopEmbed = (page) => { - const startIndex = (page - 1) * ITEMS_PER_PAGE; - const pageItems = shopItems.slice(startIndex, startIndex + ITEMS_PER_PAGE); - const embed = new EmbedBuilder() - .setTitle('Store') - .setColor(getColor('primary')) - .setDescription('Use `/buy item_id: quantity:` to purchase an item.'); - pageItems.forEach(item => { - embed.addFields({ - name: `${item.name} (${item.id})`, - value: `**Type:** ${item.type}\n **Price:** $${item.price.toLocaleString()}\n${item.description}`, - inline: false, - }); - }); - embed.setFooter({ text: `Page ${page}/${totalPages}` }); - return embed; - }; - - const createShopComponents = (page) => { - if (totalPages <= 1) return []; - return [ - new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId('shop_prev') - .setLabel('⬅️ Previous') - .setStyle(ButtonStyle.Secondary) - .setDisabled(page === 1), - new ButtonBuilder() - .setCustomId('shop_next') - .setLabel('Next ➡️') - .setStyle(ButtonStyle.Secondary) - .setDisabled(page === totalPages), - ), - ]; - }; - - const message = await interaction.reply({ - embeds: [createShopEmbed(currentPage)], - components: createShopComponents(currentPage), - flags: 0, - }); - - const collector = message.createMessageComponentCollector({ - componentType: ComponentType.Button, - time: 300000, - }); - - collector.on('collect', async (buttonInteraction) => { - if (buttonInteraction.user.id !== interaction.user.id) { - await buttonInteraction.reply({ content: '❌ You cannot use these buttons. Run `/shop` to get your own shop view.', flags: 64 }); - return; - } - const { customId } = buttonInteraction; - if (customId === 'shop_prev' || customId === 'shop_next') { - await buttonInteraction.deferUpdate(); - if (customId === 'shop_prev' && currentPage > 1) currentPage--; - else if (customId === 'shop_next' && currentPage < totalPages) currentPage++; - await buttonInteraction.editReply({ - embeds: [createShopEmbed(currentPage)], - components: createShopComponents(currentPage), - }); - } - }); - - collector.on('end', async () => { - try { - const disabledComponents = createShopComponents(currentPage); - disabledComponents.forEach(row => row.components.forEach(btn => btn.setDisabled(true))); - await message.edit({ components: disabledComponents }); - } catch (_) {} - }); - } catch (error) { - logger.error('shop_browse error:', error); - await interaction.reply({ content: '❌ An error occurred while loading the shop.', flags: MessageFlags.Ephemeral }); - } - }, -}; \ 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 53a8bbca9..000000000 --- a/src/commands/Economy/modules/shop_config_setrole.js +++ /dev/null @@ -1,30 +0,0 @@ -import { PermissionsBitField } from 'discord.js'; -import { successEmbed } from '../../../utils/embeds.js'; -import { getGuildConfig, setGuildConfig } from '../../../services/guildConfig.js'; -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -import { logger } from '../../../utils/logger.js'; - -export default { - async execute(interaction, config, client) { - if (!interaction.member.permissions.has(PermissionsBitField.Flags.ManageGuild)) { - return 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 5e8e90486..000000000 --- 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 dec0ceff3..000000000 --- a/src/commands/Economy/rob.js +++ /dev/null @@ -1,153 +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'; - -const ROB_COOLDOWN = 4 * 60 * 60 * 1000; -const BASE_ROB_SUCCESS_CHANCE = 0.25; -const ROB_PERCENTAGE = 0.15; -const FINE_PERCENTAGE = 0.1; - -export default { - data: new SlashCommandBuilder() - .setName('rob') - .setDescription('Attempt to rob another user (very risky)') - .addUserOption(option => - option - .setName('user') - .setDescription('User to rob') - .setRequired(true) - ), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const robberId = interaction.user.id; - const victimUser = interaction.options.getUser("user"); - const guildId = interaction.guildId; - const now = Date.now(); - - if (robberId === victimUser.id) { - throw createError( - "Cannot rob self", - ErrorTypes.VALIDATION, - "You cannot rob yourself.", - { robberId, victimId: victimUser.id } - ); - } - - if (victimUser.bot) { - throw createError( - "Cannot rob bot", - ErrorTypes.VALIDATION, - "You cannot rob a bot.", - { victimId: victimUser.id, isBot: true } - ); - } - - const robberData = await getEconomyData(client, guildId, robberId); - const victimData = await getEconomyData(client, guildId, victimUser.id); - - if (!robberData || !victimData) { - throw createError( - "Failed to load economy data", - ErrorTypes.DATABASE, - "Failed to load economy data. Please try again later.", - { robberId: !!robberData, victimId: !!victimData, guildId } - ); - } - - const lastRob = robberData.lastRob || 0; - - if (now < lastRob + ROB_COOLDOWN) { - const remaining = lastRob + ROB_COOLDOWN - now; - const hours = Math.floor(remaining / (1000 * 60 * 60)); - const minutes = Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60)); - - throw createError( - "Robbery cooldown active", - ErrorTypes.RATE_LIMIT, - `You need to lay low. Wait **${hours}h ${minutes}m** before attempting another robbery.`, - { remaining, hours, minutes, cooldownType: 'rob' } - ); - } - - if (victimData.wallet < 500) { - throw createError( - "Victim too poor", - ErrorTypes.VALIDATION, - `${victimUser.username} is too poor. They need at least $500 cash to be worth robbing.`, - { victimWallet: victimData.wallet, required: 500 } - ); - } - - const hasSafe = victimData.inventory["personal_safe"] || 0; - - if (hasSafe > 0) { - robberData.lastRob = now; - await setEconomyData(client, guildId, robberId, robberData); - - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - 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 4 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 fb3be41dc..000000000 --- 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 16b2b62d9..000000000 --- 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 98b83e369..000000000 --- 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 bf45af02a..000000000 --- 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 1f7f1979b..000000000 --- a/src/commands/Economy/work.js +++ /dev/null @@ -1,122 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -const WORK_COOLDOWN = 30 * 60 * 1000; -const MIN_WORK_AMOUNT = 50; -const MAX_WORK_AMOUNT = 300; -const LAPTOP_MULTIPLIER = 1.5; -const WORK_JOBS = [ - "Software Developer", - "Barista", - "Janitor", - "YouTuber", - "Discord Bot Developer", - "Cashier", - "Pizza Delivery Driver", - "Librarian", - "Gardener", - "Data Analyst", -]; - -export default { - data: new SlashCommandBuilder() - .setName('work') - .setDescription('Work to earn some money'), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userId = interaction.user.id; - const guildId = interaction.guildId; - const now = Date.now(); - - const userData = await getEconomyData(client, guildId, userId); - - if (!userData) { - throw createError( - "Failed to load economy data for work", - ErrorTypes.DATABASE, - "Failed to load your economy data. Please try again later.", - { userId, guildId } - ); - } - - logger.debug(`[ECONOMY] Work command started for ${userId}`, { userId, guildId }); - - const lastWork = userData.lastWork || 0; - const inventory = userData.inventory || {}; - const extraWorkShifts = inventory["extra_work"] || 0; - const hasLaptop = inventory["laptop"] || 0; - - let cooldownActive = now < lastWork + WORK_COOLDOWN; - let usedConsumable = false; - - if (cooldownActive) { - if (extraWorkShifts > 0) { - inventory["extra_work"] = (inventory["extra_work"] || 0) - 1; - usedConsumable = true; - } else { - const remaining = lastWork + WORK_COOLDOWN - now; - throw createError( - "Work cooldown active", - ErrorTypes.RATE_LIMIT, - `You're working too fast! Wait **${Math.floor(remaining / 3600000)}h ${Math.floor((remaining % 3600000) / 60000)}m** before working again.`, - { timeRemaining: remaining, cooldownType: 'work' } - ); - } - } - - let earned = Math.floor(Math.random() * (MAX_WORK_AMOUNT - MIN_WORK_AMOUNT + 1)) + MIN_WORK_AMOUNT; - const job = WORK_JOBS[Math.floor(Math.random() * WORK_JOBS.length)]; - - let multiplierMessage = ""; - if (hasLaptop > 0) { - earned = Math.floor(earned * LAPTOP_MULTIPLIER); - multiplierMessage = "\n💻 **Laptop Bonus:** +50% earnings!"; - } - - userData.wallet = (userData.wallet || 0) + earned; - userData.lastWork = now; - - await setEconomyData(client, guildId, userId, userData); - - logger.info(`[ECONOMY_TRANSACTION] Work completed`, { - userId, - guildId, - amount: earned, - job, - usedConsumable, - hasLaptop: hasLaptop > 0, - newWallet: userData.wallet, - timestamp: new Date().toISOString() - }); - - const embed = successEmbed( - "💼 Work Complete!", - `You worked as a **${job}** and earned **$${earned.toLocaleString()}**!${multiplierMessage}` - ) - .addFields( - { - name: "New Balance", - value: `$${userData.wallet.toLocaleString()}`, - inline: true, - }, - { - name: "Next Work", - value: ``, - inline: true, - } - ) - .setFooter({ - text: `Requested by ${interaction.user.tag}`, - iconURL: interaction.user.displayAvatarURL(), - }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - }, { command: 'work' }) -}; \ No newline at end of file diff --git a/src/commands/Fun/fight.js b/src/commands/Fun/fight.js deleted file mode 100644 index 0e64e8771..000000000 --- a/src/commands/Fun/fight.js +++ /dev/null @@ -1,92 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { successEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -const rand = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min; -const EMBED_DESCRIPTION_LIMIT = 4096; - -export default { - data: new SlashCommandBuilder() - .setName("fight") - .setDescription("Starts a simulated 1v1 text-based battle.") - .addUserOption((option) => - option - .setName("opponent") - .setDescription("The user to fight.") - .setRequired(true), - ), - category: 'Fun', - - async execute(interaction, config, client) { - try { - await InteractionHelper.safeDefer(interaction); - - const challenger = interaction.user; - const opponent = interaction.options.getUser("opponent"); - - if (challenger.id === opponent.id) { - const embed = warningEmbed( - `**${challenger.username}**, you can't fight yourself! That's a draw before it even starts.`, - "⚔️ Invalid Challenge" - ); - return await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - } - - if (opponent.bot) { - const embed = warningEmbed( - "You can't fight bots! Challenge a real person instead.", - "⚔️ Invalid Opponent" - ); - return await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - } - - const winner = rand(0, 1) === 0 ? challenger : opponent; - const loser = winner.id === challenger.id ? opponent : challenger; - const rounds = rand(3, 7); - const damage = rand(10, 50); - - const log = []; - log.push( - `💥 **${challenger.username}** challenges **${opponent.username}** to a duel! (Best of ${rounds} rounds)`, - ); - - for (let i = 1; i <= rounds; i++) { - const attacker = rand(0, 1) === 0 ? challenger : opponent; - const target = attacker.id === challenger.id ? opponent : challenger; - const action = [ - "throws a wild punch", - "lands a critical hit", - "uses a weak spell", - "parries and counterattacks", - ][rand(0, 3)]; - log.push( - `\n**Round ${i}:** ${attacker.username} ${action} on ${target.username} for ${rand(1, damage)} damage!`, - ); - } - - const outcomeText = log.join("\n"); - const winnerText = `👑 **${winner.username}** has defeated ${loser.username} and claims the victory!`; - const fullDescription = `${outcomeText}\n\n${winnerText}`; - - const description = fullDescription.length <= EMBED_DESCRIPTION_LIMIT - ? fullDescription - : `${fullDescription.slice(0, EMBED_DESCRIPTION_LIMIT - 15)}\n\n...`; - - const embed = successEmbed( - description, - "🏆 Duel Complete!" - ); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - logger.debug(`Fight command executed between ${challenger.id} and ${opponent.id} in guild ${interaction.guildId}`); - } catch (error) { - logger.error('Fight command error:', error); - await handleInteractionError(interaction, error, { - commandName: 'fight', - source: 'fight_command' - }); - } - }, -}; \ No newline at end of file diff --git a/src/commands/Fun/flip.js b/src/commands/Fun/flip.js deleted file mode 100644 index 115a011ea..000000000 --- a/src/commands/Fun/flip.js +++ /dev/null @@ -1,33 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName("flip") - .setDescription("Flips a coin (Heads or Tails)."), - category: 'Fun', - - async execute(interaction, config, client) { - try { - const result = Math.random() < 0.5 ? "Heads" : "Tails"; - const emoji = result === "Heads" ? "🪙" : "🔮"; - - const embed = successEmbed( - "Heads or Tails?", - `The coin landed on... **${result}** ${emoji}!`, - ); - - await InteractionHelper.safeReply(interaction, { embeds: [embed] }); - logger.debug(`Flip command executed by user ${interaction.user.id} in guild ${interaction.guildId}`); - } catch (error) { - logger.error('Flip command error:', error); - await handleInteractionError(interaction, error, { - commandName: 'flip', - source: 'flip_command' - }); - } - }, -}; \ No newline at end of file diff --git a/src/commands/Fun/roll.js b/src/commands/Fun/roll.js deleted file mode 100644 index b502d958f..000000000 --- a/src/commands/Fun/roll.js +++ /dev/null @@ -1,88 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { successEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName("roll") - .setDescription("Rolls dice using standard notation (e.g., 2d20, 1d6 + 5).") - .addStringOption((option) => - option - .setName("notation") - .setDescription("The dice notation (e.g., 2d6, 1d20 + 4)") - .setRequired(true) - .setMaxLength(50), - ), - category: 'Fun', - - async execute(interaction, config, client) { - try { - await InteractionHelper.safeDefer(interaction); - - const notation = interaction.options - .getString("notation") - .toLowerCase() - .replace(/\s/g, ""); - - const match = notation.match(/^(\d*)d(\d+)([\+\-]\d+)?$/); - - if (!match) { - throw new TitanBotError( - `Invalid dice notation: ${notation}`, - ErrorTypes.USER_INPUT, - 'Invalid notation. Use format like `1d20` or `3d6+5`.' - ); - } - - const numDice = parseInt(match[1] || "1", 10); - const numSides = parseInt(match[2], 10); - const modifier = parseInt(match[3] || "0", 10); - - if (numDice < 1 || numDice > 20) { - throw new TitanBotError( - `Too many dice requested: ${numDice}`, - ErrorTypes.VALIDATION, - 'Please keep the number of dice between 1 and 20.' - ); - } - - if (numSides < 1 || numSides > 1000) { - throw new TitanBotError( - `Invalid number of sides: ${numSides}`, - ErrorTypes.VALIDATION, - 'Please keep the number of sides between 1 and 1000.' - ); - } - - let rolls = []; - let totalRoll = 0; - - for (let i = 0; i < numDice; i++) { - const roll = Math.floor(Math.random() * numSides) + 1; - rolls.push(roll); - totalRoll += roll; - } - - const finalTotal = totalRoll + modifier; - - const resultsDetail = - numDice > 1 ? `**Rolls:** ${rolls.join(" + ")}\n` : ""; - const modifierText = modifier !== 0 ? `+ (${modifier})` : ""; - - const embed = successEmbed( - `🎲 Rolling ${numDice}d${numSides}${modifier !== 0 ? match[3] : ""}`, - `${resultsDetail}**Total Roll:** ${totalRoll}${modifierText} = **${finalTotal}**`, - ); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - logger.debug(`Roll command executed by user ${interaction.user.id} with notation ${notation} in guild ${interaction.guildId}`); - } catch (error) { - await handleInteractionError(interaction, error, { - commandName: 'roll', - source: 'roll_command' - }); - } - }, -}; \ No newline at end of file diff --git a/src/commands/Search/define.js b/src/commands/Search/define.js deleted file mode 100644 index 3e9c95843..000000000 --- a/src/commands/Search/define.js +++ /dev/null @@ -1,104 +0,0 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import axios from 'axios'; -import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { getColor } from '../../config/bot.js'; - -export default { - data: new SlashCommandBuilder() - .setName('define') - .setDescription('Look up a word definition') - .addStringOption(option => - option.setName('word') - .setDescription('The word to look up') - .setRequired(true)), - async execute(interaction) { - try { - - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) { - return; - } - - const word = interaction.options.getString('word'); - - if (word.length < 2) { - logger.warn('Define command - word too short', { - userId: interaction.user.id, - word: word, - guildId: interaction.guildId - }); - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Please enter a word with at least 2 characters.' }); - } - - const response = await axios.get( - `https://api.dictionaryapi.dev/api/v2/entries/en/${encodeURIComponent(word)}`, - { timeout: 5000 } - ); - - if (!response.data || response.data.length === 0) { - return await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'No definitions found for "${word}".' }); - } - - const data = response.data[0]; - const embed = createEmbed({ - title: data.word, - description: data.phonetic ? `*${data.phonetic}*` : '', - color: 'success' - }); - - data.meanings.slice(0, 5).forEach(meaning => { - const definitions = meaning.definitions - .slice(0, 3) - .map((def, idx) => { - let text = `${idx + 1}. ${def.definition}`; - if (def.example) { - text += `\n *Example: ${def.example}*`; - } - return text; - }) - .join('\n\n'); - - if (definitions) { - embed.addFields({ - name: `**${meaning.partOfSpeech || 'Definition'}**`, - value: definitions, - inline: false - }); - } - }); - - embed.setFooter({ text: 'Powered by Free Dictionary API' }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - - logger.info('Dictionary definition retrieved', { - userId: interaction.user.id, - word: word, - guildId: interaction.guildId, - commandName: 'define' - }); - - } catch (error) { - logger.error('Dictionary lookup error', { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - word: interaction.options.getString('word'), - guildId: interaction.guildId, - commandName: 'define' - }); - - if (error.response?.status === 404) { - await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'No definitions found for "${interaction.options.getString(\'word\')}".' }); - } else { - await handleInteractionError(interaction, error, { - commandName: 'define', - source: 'dictionary_api' - }); - } - } - }, -}; \ No newline at end of file diff --git a/src/commands/Search/google.js b/src/commands/Search/google.js deleted file mode 100644 index 21b172c96..000000000 --- a/src/commands/Search/google.js +++ /dev/null @@ -1,50 +0,0 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; -import { getColor } from '../../config/bot.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName('google') - .setDescription('Search Google') - .addStringOption(option => - option.setName('query') - .setDescription('What would you like to search for?') - .setRequired(true)), - async execute(interaction) { - try { - const query = interaction.options.getString('query'); - const searchUrl = `https://www.google.com/search?q=${encodeURIComponent(query)}`; - - const embed = createEmbed({ - title: 'Google Search', - description: `[Search for "${query}"](${searchUrl})`, - color: 'info' - }) - .setFooter({ text: 'Google Search Results' }); - - await InteractionHelper.safeReply(interaction, { embeds: [embed] }); - - logger.info('Google search link generated', { - userId: interaction.user.id, - query: query, - guildId: interaction.guildId, - commandName: 'google' - }); - } catch (error) { - logger.error('Error in google command', { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'google' - }); - await handleInteractionError(interaction, error, { - commandName: 'google', - source: 'google_search' - }); - } - }, -}; \ No newline at end of file diff --git a/src/commands/Search/urban.js b/src/commands/Search/urban.js deleted file mode 100644 index bb3d2f605..000000000 --- a/src/commands/Search/urban.js +++ /dev/null @@ -1,131 +0,0 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import axios from 'axios'; -import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { getColor } from '../../config/bot.js'; - -export default { - data: new SlashCommandBuilder() - .setName('urban') - .setDescription('Search Urban Dictionary for definitions') - .addStringOption(option => - option.setName('term') - .setDescription('The term to look up on Urban Dictionary') - .setRequired(true)), - - async execute(interaction) { - try { - const term = interaction.options.getString('term'); - - if (term.length < 2) { - logger.warn('Urban command - term too short', { - userId: interaction.user.id, - term: term, - guildId: interaction.guildId - }); - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Please enter a term with at least 2 characters.' }); - } - - let deferTimer = null; - const clearDeferTimer = () => { - if (deferTimer) { - clearTimeout(deferTimer); - deferTimer = null; - } - }; - - deferTimer = setTimeout(() => { - InteractionHelper.safeDefer(interaction).catch((deferError) => { - logger.debug('Urban command defer fallback failed', { - error: deferError?.message, - interactionId: interaction.id, - commandName: 'urban' - }); - }); - }, 1500); - - const response = await axios.get( - `https://api.urbandictionary.com/v0/define?term=${encodeURIComponent(term)}`, - { timeout: 5000 } - ); - clearDeferTimer(); - - if (!response.data?.list?.length) { - return await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'No definitions found for "${term}" on Urban Dictionary.' }); - } - - const definition = response.data.list[0]; - const cleanDefinition = definition.definition.replace(/\[|\]/g, ''); - const cleanExample = definition.example.replace(/\[|\]/g, ''); - - const formattedDefinition = cleanDefinition -.replace(/\n\s*\n/g, '\n\n') - .slice(0, 2000); - - const formattedExample = cleanExample - ? `*"${cleanExample.replace(/\n/g, ' ').slice(0, 500)}..."*` - : '*No example provided*'; - - const embed = createEmbed({ - title: definition.word, - description: formattedDefinition, - color: 'info' - }) - .setURL(definition.permalink) - .addFields( - { - name: 'Example', - value: formattedExample, - inline: false - }, - { - name: 'Stats', - value: `${definition.thumbs_up.toLocaleString()} • ${definition.thumbs_down.toLocaleString()}`, - inline: true - }, - { - name: 'Author', - value: definition.author || 'Anonymous', - inline: true - } - ) - .setFooter({ - text: 'Urban Dictionary', - iconURL: 'https://i.imgur.com/8aQrX3a.png' - }); - - await InteractionHelper.safeReply(interaction, { embeds: [embed] }); - - logger.info('Urban Dictionary definition retrieved', { - userId: interaction.user.id, - term: term, - guildId: interaction.guildId, - commandName: 'urban' - }); - - } catch (error) { - logger.error('Urban Dictionary error', { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - term: interaction.options.getString('term'), - guildId: interaction.guildId, - apiStatus: error.response?.status, - commandName: 'urban' - }); - - if (error.response?.status === 404 || !error.response) { - await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'No definitions found for "${interaction.options.getString(\'term\')}" on Urban Dictionary.' }); - } else if (error.response?.status === 429) { - await replyUserError(interaction, { type: ErrorTypes.RATE_LIMIT, message: 'Too many requests to Urban Dictionary. Please try again in a few minutes.' }); - } else { - await handleInteractionError(interaction, error, { - commandName: 'urban', - source: 'urban_dictionary_api' - }); - } - } - }, -}; \ No newline at end of file diff --git a/src/commands/Tools/calculate.js b/src/commands/Tools/calculate.js deleted file mode 100644 index 730a00cc6..000000000 --- a/src/commands/Tools/calculate.js +++ /dev/null @@ -1,333 +0,0 @@ -import { SlashCommandBuilder, ButtonBuilder, ButtonStyle, ActionRowBuilder } from 'discord.js'; -import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError, replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { evaluateMathExpression } from '../../utils/safeMathParser.js'; - -const calculationContexts = new Map(); - -function evaluate(expression) { - return evaluateMathExpression(expression); -} - -const calculationHistory = new Map(); -const MAX_HISTORY = 5; - -export { calculationContexts }; - -export default { - data: new SlashCommandBuilder() - .setName("calculate") - .setDescription("Evaluate a mathematical expression") - .addStringOption((option) => - option - .setName("expression") - .setDescription( - "The mathematical expression to evaluate (e.g., 2+2*3, sin(45 deg), 16^0.5)", - ) - .setRequired(true), - ), - - async execute(interaction) { - const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) { - logger.warn(`Calculate interaction defer failed`, { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'calculate' - }); - return; - } - -try { - - const expression = interaction.options.getString("expression"); - - if ( - !/^[0-9+\-*/.()^%! ,<>=&|~?:\[\]{}a-z√π∞°]+$/i.test(expression) - ) { - return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: '"**Contains unsupported characters.**\\n\\n" +\n "✅ Supported: Numbers, decimals, + - * / ^ %, sin cos tan sqrt abs log exp, pi e, ()\\n" +\n "❌ Not supported: Brackets, curly braces, and other symbols"' }); - } - - const dangerousPatterns = [ - /\b(?:import|require|process|fs|child_process|exec|eval|Function|setTimeout|setInterval|new\s+Function)\s*\(/i, - /`/g, -/\$\{.*\}/, - /\b(?:localStorage|document|window|fetch|XMLHttpRequest)\b/, - /\b(?:while|for)\s*\([^)]*\)\s*\{/, - /\b(?:function\*|yield|await|async)\b/, - ]; - - for (const pattern of dangerousPatterns) { - if (pattern.test(expression)) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: '"**Contains blocked code patterns.**\\n\\n" +\n "🚫 **Blocked:** import, require, eval, Function, setTimeout, setInterval, process, fs, document, window, fetch, loops, async/await\\n\\n" +\n "Code-like syntax is not allowed in calculations."' }); - } - } - - let result; - try { - result = evaluate(expression); - - let formattedResult; - if (typeof result === "number") { - formattedResult = result.toLocaleString("en-US", { - maximumFractionDigits: 10, - }); - - if ( - Math.abs(result) > 0 && - (Math.abs(result) >= 1e10 || Math.abs(result) < 1e-3) - ) { - formattedResult = result.toExponential(6); - } - } else if (typeof result === "boolean") { - formattedResult = result ? "true" : "false"; - } else if (result === null || result === undefined) { - formattedResult = "No result"; - } else if ( - Array.isArray(result) || - typeof result === "object" - ) { - formattedResult = - "```json\n" + JSON.stringify(result, null, 2) + "\n```"; - } else { - formattedResult = String(result); - } - - const userId = interaction.user.id; - if (!calculationHistory.has(userId)) { - calculationHistory.set(userId, []); - } - - const history = calculationHistory.get(userId); - history.unshift({ - expression, - result: formattedResult, - timestamp: Date.now(), - }); - - if (history.length > MAX_HISTORY) { - history.pop(); - } - - const row = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(`calc_${interaction.id}_add`) - .setLabel("+") - .setStyle(ButtonStyle.Primary), - new ButtonBuilder() - .setCustomId(`calc_${interaction.id}_subtract`) - .setLabel("-") - .setStyle(ButtonStyle.Primary), - new ButtonBuilder() - .setCustomId(`calc_${interaction.id}_multiply`) - .setLabel("×") - .setStyle(ButtonStyle.Primary), - new ButtonBuilder() - .setCustomId(`calc_${interaction.id}_divide`) - .setLabel("÷") - .setStyle(ButtonStyle.Primary), - new ButtonBuilder() - .setCustomId(`calc_${interaction.id}_history`) - .setLabel("History") - .setStyle(ButtonStyle.Secondary), - ); - - const embed = successEmbed( - "🧮 Calculation Result", - `**Expression:** \`${expression.replace(/`/g, "\`")}\`\n` + - `**Result:** \`${formattedResult}\`\n\n` + - `*Use the buttons below to perform operations with the result.*`, - ); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [embed], - components: [row], - }); - - const filter = (i) => - i.customId.startsWith(`calc_${interaction.id}`) && - i.user.id === interaction.user.id; -const BUTTON_TIMEOUT = 300000; - const collector = - interaction.channel.createMessageComponentCollector({ - filter, - time: BUTTON_TIMEOUT, - }); - - collector.on("collect", async (i) => { - try { - const operation = i.customId.split("_")[2]; - - if (operation === "history") { - if (!i.deferred && !i.replied) { - await i.deferUpdate().catch(console.error); - } - - const userHistory = - calculationHistory.get(userId) || []; - - if (userHistory.length === 0) { - await i.followUp({ - content: "No calculation history found.", - flags: ["Ephemeral"], - }); - return; - } - - const historyText = userHistory - .map( - (item, index) => - `${index + 1}. **${item.expression}** = \`${item.result}\`\n` + - ``, - ) - .join("\n\n"); - - await i.followUp({ - content: `📜 **Your Calculation History**\n\n${historyText}`, - flags: ["Ephemeral"], - }); - return; - } - - let operator = ""; - - switch (operation) { - case "add": - operator = "+"; - break; - case "subtract": - operator = "-"; - break; - case "multiply": - operator = "*"; - break; - case "divide": - operator = "/"; - break; - } - - try { - const contextKey = `${i.user.id}_${operation}`; - calculationContexts.set(contextKey, { - expression, - formattedResult, - operator, - messageId: interaction.message?.id, - channelId: interaction.channelId, - userId: i.user.id - }); - - await i.showModal({ - customId: `calc_modal:${operation}`, - title: `Enter a number to ${operation}`, - components: [ - { - type: 1, - components: [ - { - type: 4, - customId: `operand:${contextKey}`, - label: `Number to ${operator} with ${formattedResult}`, - placeholder: "Enter a number...", - style: 1, - required: true, - maxLength: 50, - }, - ], - }, - ], - }); - } catch (modalError) { - logger.error("Failed to show modal:", modalError); - if (!i.replied && !i.deferred) { - await i.reply({ - content: "Failed to open calculator. Please try again.", - flags: ["Ephemeral"], - }).catch(console.error); - } - return; - } - - } catch (error) { - logger.error("Button interaction error:", error); - if (!i.deferred && !i.replied) { - await i.followUp({ - content: "An error occurred while processing your request.", - flags: ["Ephemeral"], - }).catch(console.error); - } - } - }); - - collector.on("end", (collected, reason) => { - if (reason === "timeout") { - const disabledRow = - new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId( - `calc_${interaction.id}_expired`, - ) - .setLabel("Calculator Expired") - .setStyle(ButtonStyle.Secondary) - .setDisabled(true), - ); - - interaction - .editReply({ - components: [disabledRow], - content: - "⏱️ This calculator has expired. Use the command again to perform more calculations.", - }) - .catch(console.error); - } else { - const disabledRow = ActionRowBuilder.from( - row, - ).setComponents( - row.components.map((component) => - ButtonBuilder.from(component).setDisabled(true), - ), - ); - - interaction - .editReply({ components: [disabledRow] }) - .catch(console.error); - } - }); - } catch (error) { - logger.error('Calculation error:', error); - - let errorMessage = 'Failed to evaluate the expression.'; - - if (error.message.includes('Unexpected type')) { - errorMessage += - 'The expression contains an unsupported operation or function.'; - } else if (error.message.includes('Undefined symbol')) { - errorMessage += - 'The expression contains an undefined variable or function.'; - } else if (error.message.includes('Brackets not balanced')) { - errorMessage += 'The expression has unbalanced brackets.'; - } else if ( - error.message.includes('Unexpected operator') || - error.message.includes('Unexpected character') - ) { - errorMessage += - 'The expression contains an invalid operator or character.'; - } else { - errorMessage += 'Please check the syntax and try again.'; - } - - await replyUserError(interaction, { - type: ErrorTypes.VALIDATION, - message: errorMessage, - }); - } - } catch (error) { - await handleInteractionError(interaction, error, { - type: 'command', - commandName: 'calculate' - }); - } - }, -}; \ No newline at end of file diff --git a/src/commands/Tools/countdown.js b/src/commands/Tools/countdown.js deleted file mode 100644 index 80a85ecf9..000000000 --- a/src/commands/Tools/countdown.js +++ /dev/null @@ -1,104 +0,0 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import { successEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { createControlButtons, formatTime, startCountdown } from '../../handlers/countdownButtons.js'; - -const activeCountdowns = new Map(); - -export { activeCountdowns }; - -export default { - data: new SlashCommandBuilder() - .setName("countdown") - .setDescription("Start a countdown timer") - .addIntegerOption((option) => - option - .setName("minutes") - .setDescription("Number of minutes to count down (0-1440)") - .setMinValue(0) - .setMaxValue(1440) - .setRequired(false), - ) - .addIntegerOption((option) => - option - .setName("seconds") - .setDescription("Number of seconds to count down (0-59)") - .setMinValue(0) - .setMaxValue(59) - .setRequired(false), - ) - .addStringOption((option) => - option - .setName("title") - .setDescription("Optional title for the countdown") - .setRequired(false), - ), - - async execute(interaction) { - const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) { - logger.warn(`Countdown interaction defer failed`, { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'countdown' - }); - return; - } - - try { - const minutes = interaction.options.getInteger("minutes") || 0; - const seconds = interaction.options.getInteger("seconds") || 0; - const title = interaction.options.getString("title") || "Countdown Timer"; - - const totalSeconds = minutes * 60 + seconds; - - if (totalSeconds <= 0) { - throw new Error("Please specify a duration of at least 1 second."); - } - - if (totalSeconds > 86400) { - throw new Error("Countdown cannot be longer than 24 hours."); - } - - const endTime = Date.now() + totalSeconds * 1000; - const countdownId = `${interaction.channelId}-${Date.now()}`; - - const row = createControlButtons(countdownId); - - const initialEmbed = successEmbed( - `⏱️ ${title}`, - `Time remaining: **${formatTime(totalSeconds)}**`, - ); - - const message = await interaction.channel.send({ - embeds: [initialEmbed], - components: [row], - }); - - const countdownData = { - message, - endTime, - remainingTime: totalSeconds * 1000, - isPaused: false, - title, - lastUpdate: Date.now(), - interval: null, - }; - - activeCountdowns.set(countdownId, countdownData); - startCountdown(countdownId, countdownData, activeCountdowns); - - await InteractionHelper.safeEditReply(interaction, { - content: "✅ Countdown started!", - flags: MessageFlags.Ephemeral, - }); - } catch (error) { - await handleInteractionError(interaction, error, { - type: 'command', - commandName: 'countdown' - }); - } - }, -}; \ No newline at end of file diff --git a/src/commands/Utility/weather.js b/src/commands/Utility/weather.js deleted file mode 100644 index 1e542e183..000000000 --- a/src/commands/Utility/weather.js +++ /dev/null @@ -1,137 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -const GEOCODING_URL = "https://geocoding-api.open-meteo.com/v1/search"; -const WEATHER_URL = "https://api.open-meteo.com/v1/forecast"; - -export default { - data: new SlashCommandBuilder() - .setName("weather") - .setDescription("Get real-time weather information for a location") - .addStringOption((option) => - option - .setName("city") - .setDescription("The city name, e.g., 'London' or 'Tokyo'") - .setRequired(true), - ), - - async execute(interaction) { - try { - const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) { - logger.warn(`Weather interaction defer failed`, { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'weather' - }); - return; - } - - const city = interaction.options.getString("city"); - - const geoResponse = await fetch( - `${GEOCODING_URL}?name=${encodeURIComponent(city)}`, - ); - const geoData = await geoResponse.json(); - - if (!geoData.results || geoData.results.length === 0) { - logger.info(`Weather command - city not found`, { - userId: interaction.user.id, - city: city, - guildId: interaction.guildId - }); - await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'Could not find a location for **${city}**. Please check the spelling.' }); - return; - } - - const { latitude, longitude, name, country } = geoData.results[0]; - const cityDisplay = name; - - const weatherResponse = await fetch( - `${WEATHER_URL}?latitude=${latitude}&longitude=${longitude}¤t_weather=true`, - ); - const weatherData = await weatherResponse.json(); - - if (weatherData.error) { - logger.error(`Weather API error`, { - error: weatherData.reason, - city: city, - userId: interaction.user.id, - guildId: interaction.guildId - }); - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'A weather service error occurred.' }); - return; - } - - const current = weatherData.current || weatherData.current_weather || {}; - const temperature = current.temperature != null ? Math.round(current.temperature) : "N/A"; - const humidity = current.relativehumidity ?? current.relative_humidity_2m ?? "N/A"; - const windSpeed = current.windspeed != null ? Math.round(current.windspeed) : "N/A"; - const weatherCode = current.weathercode ?? current.weather_code ?? null; - - const condition = getWeatherDescription(weatherCode); - - const embed = createEmbed({ title: `Weather in ${cityDisplay}, ${country}`, description: condition.description }) - .addFields( - { - name: "Temperature", - value: `${temperature}°C`, - inline: true, - }, - { - name: "Humidity", - value: `${humidity}%`, - inline: true, - }, - { - name: "Wind Speed", - value: `${windSpeed} km/h`, - inline: true, - }, - ) - .setFooter({ - text: `Latitude: ${latitude.toFixed(2)} | Longitude: ${longitude.toFixed(2)}`, - }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - logger.info(`Weather command executed`, { - userId: interaction.user.id, - city: cityDisplay, - country: country, - temperature: temperature, - guildId: interaction.guildId - }); - } catch (error) { - logger.error(`Weather command execution failed`, { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'weather' - }); - await handleInteractionError(interaction, error, { - commandName: 'weather', - source: 'weather_command' - }); - } - }, -}; - -function getWeatherDescription(code) { - if (code >= 0 && code <= 3) { - return { description: "Clear sky / Partly cloudy", emoji: "" }; - } else if (code >= 45 && code <= 48) { - return { description: "Fog and Rime fog", emoji: "" }; - } else if (code >= 51 && code <= 67) { - return { description: "Drizzle or Rain", emoji: "" }; - } else if (code >= 71 && code <= 75) { - return { description: "Snow fall", emoji: "" }; - } else if (code >= 80 && code <= 86) { - return { description: "Showers (Rain/Snow)", emoji: "" }; - } else if (code >= 95 && code <= 99) { - return { description: "Thunderstorm", emoji: "" }; - } - return { description: "Unknown conditions.", emoji: "" }; -} \ No newline at end of file From ccad68600cd4244a28f7d928c13dc374a87f84df Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:16:48 -0600 Subject: [PATCH 071/115] Removed 2 useless commands --- src/commands/Moderation/massban.js | 193 ---------------------------- src/commands/Moderation/masskick.js | 185 -------------------------- 2 files changed, 378 deletions(-) delete mode 100644 src/commands/Moderation/massban.js delete mode 100644 src/commands/Moderation/masskick.js diff --git a/src/commands/Moderation/massban.js b/src/commands/Moderation/massban.js deleted file mode 100644 index 1ef637286..000000000 --- a/src/commands/Moderation/massban.js +++ /dev/null @@ -1,193 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; -import { createEmbed, successEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logModerationAction } from '../../utils/moderation.js'; -import { logger } from '../../utils/logger.js'; -import { ModerationService } from '../../services/moderationService.js'; -import { TitanBotError } from '../../utils/errorHandler.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName("massban") - .setDescription("Ban multiple users from the server at once") - .addStringOption(option => - option - .setName("users") - .setDescription("User IDs or mentions to ban (separated by spaces or commas)") - .setRequired(true) - ) - .addStringOption(option => - option.setName("reason") - .setDescription("Reason for the mass ban") - .setRequired(false) - ) - .addIntegerOption(option => - option - .setName("delete_days") - .setDescription("Number of days of messages to delete (0-7)") - .setMinValue(0) - .setMaxValue(7) - .setRequired(false) - ) - .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers), - category: "moderation", - abuseProtection: { maxAttempts: 3, windowMs: 60_000 }, - - async execute(interaction, config, client) { - const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) { - logger.warn(`Massban interaction defer failed`, { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'massban' - }); - return; - } - - if (!interaction.member.permissions.has(PermissionFlagsBits.BanMembers)) { - return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You do not have permission to ban members.' }); - } - - const usersInput = interaction.options.getString("users"); - const reason = interaction.options.getString("reason") || "Mass ban - No reason provided"; - const deleteDays = interaction.options.getInteger("delete_days") || 0; - - try { - const userIds = usersInput -.replace(/<@!?(\d+)>/g, '$1') -.split(/[\s,]+/) -.filter(id => id && /^\d+$/.test(id)) -.slice(0, 20); - - if (userIds.length === 0) { - return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Please provide valid user IDs or mentions. Maximum 20 users at once.' }); - } - - if (userIds.includes(interaction.user.id)) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'You cannot include yourself in a mass ban.' }); - } - - if (userIds.includes(client.user.id)) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'You cannot include the bot in a mass ban.' }); - } - - const results = { - successful: [], - failed: [], - skipped: [] - }; - - for (const userId of userIds) { - try { - const user = await client.users.fetch(userId).catch(() => null); - - if (!user) { - results.failed.push({ userId, reason: "User not found" }); - continue; - } - - const member = await interaction.guild.members.fetch(userId).catch(() => null); - - if (member) { - const modCheck = ModerationService.validateHierarchy(interaction.member, member, 'ban'); - if (!modCheck.valid) { - results.skipped.push({ - user: user.tag, - userId, - reason: ModerationService.buildHierarchySkipReason(interaction.member, member, 'ban'), - }); - continue; - } - - const botCheck = ModerationService.validateBotHierarchy(member, 'ban'); - if (!botCheck.valid) { - results.skipped.push({ - user: user.tag, - userId, - reason: ModerationService.buildHierarchySkipReason(interaction.member, member, 'ban', 'bot'), - }); - continue; - } - } - - await interaction.guild.members.ban(userId, { - reason: reason, - deleteMessageDays: deleteDays - }); - - results.successful.push({ - user: user.tag, - userId - }); - - await logModerationAction({ - client, - guild: interaction.guild, - event: { - action: "Member Banned", - target: `${user.tag} (${user.id})`, - executor: `${interaction.user.tag} (${interaction.user.id})`, - reason: `${reason} (Mass Ban)`, - metadata: { - userId: user.id, - moderatorId: interaction.user.id, - massBan: true, - permanent: true - } - } - }); - - } catch (error) { - logger.error(`Failed to ban user ${userId}:`, error); - const reason = error instanceof TitanBotError - ? (error.userMessage || error.message) - : (error.message || "Unknown error"); - results.failed.push({ - userId, - reason, - }); - } - } - - let description = `**Mass Ban Results:**\n\n`; - - if (results.successful.length > 0) { - description += `✅ **Successfully Banned (${results.successful.length}):**\n`; - results.successful.forEach(result => { - description += `• ${result.user} (${result.userId})\n`; - }); - description += '\n'; - } - - if (results.skipped.length > 0) { - description += `⚠️ **Skipped (${results.skipped.length}):**\n`; - results.skipped.forEach(result => { - description += `• ${result.user} - ${result.reason}\n`; - }); - description += '\n'; - } - - if (results.failed.length > 0) { - description += `❌ **Failed (${results.failed.length}):**\n`; - results.failed.forEach(result => { - description += `• ${result.userId} - ${result.reason}\n`; - }); - } - - const embed = results.successful.length > 0 ? successEmbed : warningEmbed; - - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - embed( - `🔨 Mass Ban Completed`, - description - ) - ] - }); - - } catch (error) { - logger.error("Error in massban command:", error); - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while processing the mass ban. Please try again later.' }); - } - } -}; \ No newline at end of file diff --git a/src/commands/Moderation/masskick.js b/src/commands/Moderation/masskick.js deleted file mode 100644 index 6d95bbda5..000000000 --- a/src/commands/Moderation/masskick.js +++ /dev/null @@ -1,185 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; -import { createEmbed, successEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logModerationAction } from '../../utils/moderation.js'; -import { logger } from '../../utils/logger.js'; -import { ModerationService } from '../../services/moderationService.js'; -import { TitanBotError } from '../../utils/errorHandler.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName("masskick") - .setDescription("Kick multiple users from the server at once") - .addStringOption(option => - option - .setName("users") - .setDescription("User IDs or mentions to kick (separated by spaces or commas)") - .setRequired(true) - ) - .addStringOption(option => - option.setName("reason") - .setDescription("Reason for the mass kick") - .setRequired(false) - ) - .setDefaultMemberPermissions(PermissionFlagsBits.KickMembers), - category: "moderation", - abuseProtection: { maxAttempts: 3, windowMs: 60_000 }, - - async execute(interaction, config, client) { - const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) { - logger.warn(`Masskick interaction defer failed`, { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'masskick' - }); - return; - } - - if (!interaction.member.permissions.has(PermissionFlagsBits.KickMembers)) { - return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You do not have permission to kick members.' }); - } - - const usersInput = interaction.options.getString("users"); - const reason = interaction.options.getString("reason") || "Mass kick - No reason provided"; - - try { - const userIds = usersInput -.replace(/<@!?(\d+)>/g, '$1') -.split(/[\s,]+/) -.filter(id => id && /^\d+$/.test(id)) -.slice(0, 20); - - if (userIds.length === 0) { - return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Please provide valid user IDs or mentions. Maximum 20 users at once.' }); - } - - if (userIds.includes(interaction.user.id)) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'You cannot include yourself in a mass kick.' }); - } - - if (userIds.includes(client.user.id)) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'You cannot include the bot in a mass kick.' }); - } - - const results = { - successful: [], - failed: [], - skipped: [] - }; - - for (const userId of userIds) { - try { - const member = await interaction.guild.members.fetch(userId).catch(() => null); - - if (!member) { - results.failed.push({ userId, reason: "User not in server" }); - continue; - } - - const modCheck = ModerationService.validateHierarchy(interaction.member, member, 'kick'); - if (!modCheck.valid) { - results.skipped.push({ - user: member.user.tag, - userId, - reason: ModerationService.buildHierarchySkipReason(interaction.member, member, 'kick'), - }); - continue; - } - - const botCheck = ModerationService.validateBotHierarchy(member, 'kick'); - if (!botCheck.valid) { - results.skipped.push({ - user: member.user.tag, - userId, - reason: ModerationService.buildHierarchySkipReason(interaction.member, member, 'kick', 'bot'), - }); - continue; - } - - if (!member.kickable) { - results.skipped.push({ - user: member.user.tag, - userId, - reason: 'Target has Admin or a managed role, or bot lacks Kick Members', - }); - continue; - } - - await member.kick(reason); - - results.successful.push({ - user: member.user.tag, - userId - }); - - await logModerationAction({ - client, - guild: interaction.guild, - event: { - action: "Member Kicked", - target: `${member.user.tag} (${member.user.id})`, - executor: `${interaction.user.tag} (${interaction.user.id})`, - reason: `${reason} (Mass Kick)`, - metadata: { - userId: member.user.id, - moderatorId: interaction.user.id, - massKick: true - } - } - }); - - } catch (error) { - logger.error(`Failed to kick user ${userId}:`, error); - const reason = error instanceof TitanBotError - ? (error.userMessage || error.message) - : (error.message || "Unknown error"); - results.failed.push({ - userId, - reason, - }); - } - } - - let description = `**Mass Kick Results:**\n\n`; - - if (results.successful.length > 0) { - description += `✅ **Successfully Kicked (${results.successful.length}):**\n`; - results.successful.forEach(result => { - description += `• ${result.user} (${result.userId})\n`; - }); - description += '\n'; - } - - if (results.skipped.length > 0) { - description += `⚠️ **Skipped (${results.skipped.length}):**\n`; - results.skipped.forEach(result => { - description += `• ${result.user} - ${result.reason}\n`; - }); - description += '\n'; - } - - if (results.failed.length > 0) { - description += `❌ **Failed (${results.failed.length}):**\n`; - results.failed.forEach(result => { - description += `• ${result.userId} - ${result.reason}\n`; - }); - } - - const embed = results.successful.length > 0 ? successEmbed : warningEmbed; - - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - embed( - `👢 Mass Kick Completed`, - description - ) - ] - }); - - } catch (error) { - logger.error("Error in masskick command:", error); - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while processing the mass kick. Please try again later.' }); - } - } -}; \ No newline at end of file From 502267fcf6c6bb382a768dbde96a6b2087638741 Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Wed, 24 Jun 2026 21:12:47 -0600 Subject: [PATCH 072/115] Update punish.js --- src/commands/Moderation/punish.js | 119 +++++++++++++++++++++++++++++- 1 file changed, 118 insertions(+), 1 deletion(-) diff --git a/src/commands/Moderation/punish.js b/src/commands/Moderation/punish.js index 91e36243c..f01ae6056 100644 --- a/src/commands/Moderation/punish.js +++ b/src/commands/Moderation/punish.js @@ -13,6 +13,9 @@ import { InteractionHelper } from '../../utils/interactionHelper.js'; import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; const PUNISHMENT_LOG_CHANNEL_ID = '1517145309015314442'; +const WARNING_ROLE_ID = '1519540353881866404'; +const MUTED_ROLE_ID = '1519537206182809743'; +const SUSPENSION_ROLE_ID = '1519537206182809743'; const PUNISHMENT_TYPES = [ 'Verbal Warning', @@ -26,6 +29,92 @@ const PUNISHMENT_TYPES = [ 'Suspension', ]; +async function executePunishmentAction(member, punishmentType, durationStr, guild) { + const results = []; + + try { + switch (punishmentType) { + case 'Verbal Warning': + case 'Written Warning': { + // Add warning role + await member.roles.add(WARNING_ROLE_ID).catch(() => {}); + results.push(`✅ Warning role added`); + break; + } + + case 'Mute/Timeout': + case 'Temporary Ban': { + // Add muted role + await member.roles.add(MUTED_ROLE_ID).catch(() => {}); + results.push(`✅ Muted role added`); + + // Schedule role removal if duration provided + if (durationStr) { + const ms = parseDuration(durationStr); + if (ms) { + setTimeout(async () => { + try { + const freshMember = await guild.members.fetch(member.id).catch(() => null); + if (freshMember) { + await freshMember.roles.remove(MUTED_ROLE_ID).catch(() => {}); + } + } catch (err) {} + }, ms); + results.push(`⏰ Muted role will be removed after ${formatDuration(durationStr)}`); + } + } + break; + } + + case 'Kick': { + await member.kick().catch(() => {}); + results.push(`✅ Member kicked`); + break; + } + + case 'Permanent Ban': + case 'Termination': { + // Add muted role permanently + await member.roles.add(MUTED_ROLE_ID).catch(() => {}); + results.push(`✅ Muted role added permanently`); + break; + } + + case 'Suspension': { + // Add suspension role + await member.roles.add(SUSPENSION_ROLE_ID).catch(() => {}); + results.push(`✅ Suspension role added`); + + // Schedule role removal if duration provided + if (durationStr) { + const ms = parseDuration(durationStr); + if (ms) { + setTimeout(async () => { + try { + const freshMember = await guild.members.fetch(member.id).catch(() => null); + if (freshMember) { + await freshMember.roles.remove(SUSPENSION_ROLE_ID).catch(() => {}); + } + } catch (err) {} + }, ms); + results.push(`⏰ Suspension role will be removed after ${formatDuration(durationStr)}`); + } + } + break; + } + + case 'Demotion': { + results.push(`📝 Demotion logged`); + break; + } + } + } catch (err) { + results.push(`⚠️ Action partially failed: ${err.message}`); + } + + return results; +} + function generateCaseCode() { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; return Array.from({ length: 8 }, () => chars[Math.floor(Math.random() * chars.length)]).join(''); @@ -119,6 +208,14 @@ export default { throw new TitanBotError('Invalid duration', ErrorTypes.USER_INPUT, 'Invalid duration format. Use formats like `30d`, `7d`, `24h`, `1w`, `30m`.', { subtype: 'invalid_duration' }); } + // Execute the punishment action + let actionResults = []; + if (member) { + actionResults = await executePunishmentAction(member, punishmentType, durationStr, interaction.guild); + } else { + actionResults = ['⚠️ Member not found in server — action skipped, log created']; + } + const caseCode = generateCaseCode(); const now = new Date(); const formattedDate = now.toLocaleDateString('en-US', { @@ -173,6 +270,14 @@ export default { .setThumbnail(user.displayAvatarURL({ dynamic: true, size: 128 })) .setTimestamp(); + if (actionResults.length > 0) { + embed.addFields({ + name: 'Actions Taken', + value: actionResults.join('\n'), + inline: false, + }); + } + if (durationStr) { embed.addFields( { name: 'Active For', value: formatDuration(durationStr), inline: true }, @@ -196,8 +301,20 @@ export default { const buttons = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`punish_reviewed_${caseCode}`) - .setLabel('✅ Reviewed by Management') + .setLabel('✅ Reviewed by IA/HC') .setStyle(ButtonStyle.Success), + new ButtonBuilder() + .setCustomId(`punish_processed_${caseCode}`) + .setLabel('Department Hub Processed') + .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId(`punish_roster_${caseCode}`) + .setLabel('Roles & Roster Updated') + .setStyle(ButtonStyle.Danger), + new ButtonBuilder() + .setCustomId(`punish_rosterlink_${caseCode}`) + .setLabel('Roster') + .setStyle(ButtonStyle.Secondary), ); // Send to punishment log forum channel From f347d081761f25c8352c740bfbee121b1783fee8 Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Wed, 24 Jun 2026 21:15:06 -0600 Subject: [PATCH 073/115] Update punish.js --- src/commands/Moderation/punish.js | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/commands/Moderation/punish.js b/src/commands/Moderation/punish.js index f01ae6056..7f794ab3b 100644 --- a/src/commands/Moderation/punish.js +++ b/src/commands/Moderation/punish.js @@ -204,14 +204,15 @@ export default { } // Validate duration format if provided - if (durationStr && !parseDuration(durationStr)) { + const cleanDuration = durationStr?.trim() || null; + if (cleanDuration && !parseDuration(cleanDuration)) { throw new TitanBotError('Invalid duration', ErrorTypes.USER_INPUT, 'Invalid duration format. Use formats like `30d`, `7d`, `24h`, `1w`, `30m`.', { subtype: 'invalid_duration' }); } // Execute the punishment action let actionResults = []; if (member) { - actionResults = await executePunishmentAction(member, punishmentType, durationStr, interaction.guild); + actionResults = await executePunishmentAction(member, punishmentType, cleanDuration, interaction.guild); } else { actionResults = ['⚠️ Member not found in server — action skipped, log created']; } @@ -225,8 +226,8 @@ export default { // Calculate expiry if duration provided let expiresText = null; - if (durationStr) { - const ms = parseDuration(durationStr); + if (cleanDuration) { + const ms = parseDuration(cleanDuration); const expiryDate = new Date(now.getTime() + ms); expiresText = expiryDate.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', @@ -278,9 +279,9 @@ export default { }); } - if (durationStr) { + if (cleanDuration) { embed.addFields( - { name: 'Active For', value: formatDuration(durationStr), inline: true }, + { name: 'Active For', value: formatDuration(cleanDuration), inline: true }, { name: 'Expires', value: expiresText, inline: true }, ); } else { @@ -352,7 +353,7 @@ export default { target: `${user.tag} (${user.id})`, executor: `${interaction.user.tag} (${interaction.user.id})`, reason, - duration: durationStr ? formatDuration(durationStr) : 'Permanent', + duration: cleanDuration ? formatDuration(cleanDuration) : 'Permanent', metadata: { userId: user.id, moderatorId: interaction.user.id, @@ -373,7 +374,7 @@ export default { target: `${user.tag} (${user.id})`, executor: `${interaction.user.tag} (${interaction.user.id})`, reason, - duration: durationStr ? formatDuration(durationStr) : null, + duration: cleanDuration ? formatDuration(cleanDuration) : null, caseId: caseCode, metadata: { userId: user.id, @@ -402,4 +403,4 @@ export default { await handleInteractionError(interaction, error, { subtype: 'punish_failed' }); } }, -}; +}; \ No newline at end of file From f62ca8c740fb5cefc3f61210de81d18c4627814c Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Wed, 24 Jun 2026 21:26:56 -0600 Subject: [PATCH 074/115] Update punish.js --- src/commands/Moderation/punish.js | 46 +++++++++++++++++-------------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/src/commands/Moderation/punish.js b/src/commands/Moderation/punish.js index 7f794ab3b..2a752c0f7 100644 --- a/src/commands/Moderation/punish.js +++ b/src/commands/Moderation/punish.js @@ -37,16 +37,24 @@ async function executePunishmentAction(member, punishmentType, durationStr, guil case 'Verbal Warning': case 'Written Warning': { // Add warning role - await member.roles.add(WARNING_ROLE_ID).catch(() => {}); - results.push(`✅ Warning role added`); + try { + await member.roles.add(WARNING_ROLE_ID); + results.push(`✅ Warning role added`); + } catch (roleErr) { + results.push(`❌ Failed to add warning role: ${roleErr.message}`); + } break; } case 'Mute/Timeout': case 'Temporary Ban': { // Add muted role - await member.roles.add(MUTED_ROLE_ID).catch(() => {}); - results.push(`✅ Muted role added`); + try { + await member.roles.add(MUTED_ROLE_ID); + results.push(`✅ Muted role added`); + } catch (roleErr) { + results.push(`❌ Failed to add muted role: ${roleErr.message}`); + } // Schedule role removal if duration provided if (durationStr) { @@ -75,15 +83,23 @@ async function executePunishmentAction(member, punishmentType, durationStr, guil case 'Permanent Ban': case 'Termination': { // Add muted role permanently - await member.roles.add(MUTED_ROLE_ID).catch(() => {}); - results.push(`✅ Muted role added permanently`); + try { + await member.roles.add(MUTED_ROLE_ID); + results.push(`✅ Muted role added permanently`); + } catch (roleErr) { + results.push(`❌ Failed to add muted role: ${roleErr.message}`); + } break; } case 'Suspension': { // Add suspension role - await member.roles.add(SUSPENSION_ROLE_ID).catch(() => {}); - results.push(`✅ Suspension role added`); + try { + await member.roles.add(SUSPENSION_ROLE_ID); + results.push(`✅ Suspension role added`); + } catch (roleErr) { + results.push(`❌ Failed to add suspension role: ${roleErr.message}`); + } // Schedule role removal if duration provided if (durationStr) { @@ -302,20 +318,8 @@ export default { const buttons = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`punish_reviewed_${caseCode}`) - .setLabel('✅ Reviewed by IA/HC') + .setLabel('✅ Reviewed by Management') .setStyle(ButtonStyle.Success), - new ButtonBuilder() - .setCustomId(`punish_processed_${caseCode}`) - .setLabel('Department Hub Processed') - .setStyle(ButtonStyle.Primary), - new ButtonBuilder() - .setCustomId(`punish_roster_${caseCode}`) - .setLabel('Roles & Roster Updated') - .setStyle(ButtonStyle.Danger), - new ButtonBuilder() - .setCustomId(`punish_rosterlink_${caseCode}`) - .setLabel('Roster') - .setStyle(ButtonStyle.Secondary), ); // Send to punishment log forum channel From d65d3b17b92418c53d1e9904b578393bf23fcbff Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Wed, 24 Jun 2026 21:31:43 -0600 Subject: [PATCH 075/115] Update punish.js --- src/commands/Moderation/punish.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/Moderation/punish.js b/src/commands/Moderation/punish.js index 2a752c0f7..a31e42e36 100644 --- a/src/commands/Moderation/punish.js +++ b/src/commands/Moderation/punish.js @@ -14,8 +14,8 @@ import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/e const PUNISHMENT_LOG_CHANNEL_ID = '1517145309015314442'; const WARNING_ROLE_ID = '1519540353881866404'; -const MUTED_ROLE_ID = '1519537206182809743'; -const SUSPENSION_ROLE_ID = '1519537206182809743'; +const MUTED_ROLE_ID = '1516865012554141801'; +const SUSPENSION_ROLE_ID = '1480276327578996747'; const PUNISHMENT_TYPES = [ 'Verbal Warning', From 6121af2c3d454d46e0a2309dc583b6e2b4542c5b Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Wed, 24 Jun 2026 21:33:23 -0600 Subject: [PATCH 076/115] Update punish.js --- src/commands/Moderation/punish.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/Moderation/punish.js b/src/commands/Moderation/punish.js index a31e42e36..9b337d375 100644 --- a/src/commands/Moderation/punish.js +++ b/src/commands/Moderation/punish.js @@ -14,7 +14,7 @@ import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/e const PUNISHMENT_LOG_CHANNEL_ID = '1517145309015314442'; const WARNING_ROLE_ID = '1519540353881866404'; -const MUTED_ROLE_ID = '1516865012554141801'; +const MUTED_ROLE_ID = '1480276327578996747'; const SUSPENSION_ROLE_ID = '1480276327578996747'; const PUNISHMENT_TYPES = [ From 31a3aa720a55306045242d2b11af5d30feb44096 Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Wed, 24 Jun 2026 21:36:06 -0600 Subject: [PATCH 077/115] Update punish.js --- src/commands/Moderation/punish.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/commands/Moderation/punish.js b/src/commands/Moderation/punish.js index 9b337d375..88210db9e 100644 --- a/src/commands/Moderation/punish.js +++ b/src/commands/Moderation/punish.js @@ -13,9 +13,9 @@ import { InteractionHelper } from '../../utils/interactionHelper.js'; import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; const PUNISHMENT_LOG_CHANNEL_ID = '1517145309015314442'; -const WARNING_ROLE_ID = '1519540353881866404'; -const MUTED_ROLE_ID = '1480276327578996747'; -const SUSPENSION_ROLE_ID = '1480276327578996747'; +const WARNING_ROLE_ID = '1480276327578996747'; +const MUTED_ROLE_ID = '1516865012554141801'; +const SUSPENSION_ROLE_ID = '1516865012554141801'; const PUNISHMENT_TYPES = [ 'Verbal Warning', From 9cf2c49579d0486da5ca0b88d1385a7a64aa10bb Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Wed, 24 Jun 2026 21:49:08 -0600 Subject: [PATCH 078/115] Fix some things --- src/commands/Moderation/punish.js | 72 +++++++++++++++-- src/commands/Moderation/restore.js | 119 +++++++++++++++++++++++++++++ 2 files changed, 183 insertions(+), 8 deletions(-) create mode 100644 src/commands/Moderation/restore.js diff --git a/src/commands/Moderation/punish.js b/src/commands/Moderation/punish.js index 88210db9e..9301a60f2 100644 --- a/src/commands/Moderation/punish.js +++ b/src/commands/Moderation/punish.js @@ -11,11 +11,37 @@ import { logModerationAction, generateCaseId, storeModerationCase } from '../../ import { logger } from '../../utils/logger.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; +import { getFromDb, setInDb } from '../../utils/database.js'; const PUNISHMENT_LOG_CHANNEL_ID = '1517145309015314442'; -const WARNING_ROLE_ID = '1480276327578996747'; -const MUTED_ROLE_ID = '1516865012554141801'; -const SUSPENSION_ROLE_ID = '1516865012554141801'; +const WARNING_ROLE_ID = '1519540353881866404'; +const MUTED_ROLE_ID = '1519537206182809743'; +const SUSPENSION_ROLE_ID = '1519537206182809743'; + +// Key to store saved roles per user +const SAVED_ROLES_KEY = (guildId, userId) => `saved_roles_${guildId}_${userId}`; + +async function saveAndRemoveRoles(member, guild) { + try { + // Get all roles except @everyone and bot-managed roles + const rolesToSave = member.roles.cache + .filter(r => r.id !== guild.id && !r.managed) + .map(r => r.id); + + if (rolesToSave.length === 0) return; + + // Save roles to database + await setInDb(SAVED_ROLES_KEY(guild.id, member.id), { + roles: rolesToSave, + savedAt: new Date().toISOString(), + }); + + // Remove all saved roles + await member.roles.remove(rolesToSave).catch(() => {}); + } catch (err) { + throw new Error(`Failed to save/remove roles: ${err.message}`); + } +} const PUNISHMENT_TYPES = [ 'Verbal Warning', @@ -48,7 +74,13 @@ async function executePunishmentAction(member, punishmentType, durationStr, guil case 'Mute/Timeout': case 'Temporary Ban': { - // Add muted role + // Save and remove all roles, then add muted role + try { + await saveAndRemoveRoles(member, guild); + results.push(`✅ All roles saved and removed`); + } catch (saveErr) { + results.push(`⚠️ Role save failed: ${saveErr.message}`); + } try { await member.roles.add(MUTED_ROLE_ID); results.push(`✅ Muted role added`); @@ -82,7 +114,13 @@ async function executePunishmentAction(member, punishmentType, durationStr, guil case 'Permanent Ban': case 'Termination': { - // Add muted role permanently + // Save and remove all roles, then add muted role permanently + try { + await saveAndRemoveRoles(member, guild); + results.push(`✅ All roles saved and removed`); + } catch (saveErr) { + results.push(`⚠️ Role save failed: ${saveErr.message}`); + } try { await member.roles.add(MUTED_ROLE_ID); results.push(`✅ Muted role added permanently`); @@ -93,7 +131,13 @@ async function executePunishmentAction(member, punishmentType, durationStr, guil } case 'Suspension': { - // Add suspension role + // Save and remove all roles, then add suspension role + try { + await saveAndRemoveRoles(member, guild); + results.push(`✅ All roles saved and removed`); + } catch (saveErr) { + results.push(`⚠️ Role save failed: ${saveErr.message}`); + } try { await member.roles.add(SUSPENSION_ROLE_ID); results.push(`✅ Suspension role added`); @@ -318,8 +362,20 @@ export default { const buttons = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`punish_reviewed_${caseCode}`) - .setLabel('✅ Reviewed by Management') + .setLabel('✅ Reviewed by IA/HC') .setStyle(ButtonStyle.Success), + new ButtonBuilder() + .setCustomId(`punish_processed_${caseCode}`) + .setLabel('Department Hub Processed') + .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId(`punish_roster_${caseCode}`) + .setLabel('Roles & Roster Updated') + .setStyle(ButtonStyle.Danger), + new ButtonBuilder() + .setCustomId(`punish_rosterlink_${caseCode}`) + .setLabel('Roster') + .setStyle(ButtonStyle.Secondary), ); // Send to punishment log forum channel @@ -407,4 +463,4 @@ export default { await handleInteractionError(interaction, error, { subtype: 'punish_failed' }); } }, -}; \ No newline at end of file +}; diff --git a/src/commands/Moderation/restore.js b/src/commands/Moderation/restore.js new file mode 100644 index 000000000..9e34cfbcf --- /dev/null +++ b/src/commands/Moderation/restore.js @@ -0,0 +1,119 @@ +import { + SlashCommandBuilder, + PermissionFlagsBits, + EmbedBuilder, + MessageFlags, +} from 'discord.js'; +import { successEmbed } from '../../utils/embeds.js'; +import { logger } from '../../utils/logger.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; +import { getFromDb, setInDb } from '../../utils/database.js'; + +const SAVED_ROLES_KEY = (guildId, userId) => `saved_roles_${guildId}_${userId}`; + +export default { + data: new SlashCommandBuilder() + .setName('restore') + .setDescription('Restore a member\'s saved roles after a punishment') + .addUserOption(opt => + opt.setName('member') + .setDescription('The member to restore roles for') + .setRequired(true) + ) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageRoles), + + category: 'moderation', + + async execute(interaction, config, client) { + try { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + const user = interaction.options.getUser('member'); + const member = interaction.options.getMember('member'); + + if (!member) { + throw new TitanBotError('Member not found', ErrorTypes.USER_INPUT, 'That member is not in this server.', { subtype: 'not_found' }); + } + + // Get saved roles from database + const saved = await getFromDb(SAVED_ROLES_KEY(interaction.guild.id, user.id), null); + + if (!saved || !saved.roles || saved.roles.length === 0) { + throw new TitanBotError('No saved roles', ErrorTypes.USER_INPUT, `No saved roles found for <@${user.id}>. They may not have been punished with role removal.`, { subtype: 'no_saved_roles' }); + } + + // Filter out roles that no longer exist + const restoredRoles = []; + const missingRoles = []; + + for (const roleId of saved.roles) { + const role = interaction.guild.roles.cache.get(roleId); + if (role) { + restoredRoles.push(roleId); + } else { + missingRoles.push(roleId); + } + } + + // Add roles back + if (restoredRoles.length > 0) { + await member.roles.add(restoredRoles).catch(err => { + throw new TitanBotError('Role restore failed', ErrorTypes.UNKNOWN, `Failed to restore roles: ${err.message}`, { subtype: 'restore_failed' }); + }); + } + + // Clear saved roles from database + await setInDb(SAVED_ROLES_KEY(interaction.guild.id, user.id), null); + + const embed = new EmbedBuilder() + .setColor(0x2ECC71) + .setTitle('✅ Roles Restored') + .setDescription(`Successfully restored **${restoredRoles.length}** role(s) for <@${user.id}>.`) + .addFields( + { + name: 'Restored Roles', + value: restoredRoles.map(id => `<@&${id}>`).join(', ') || 'None', + inline: false, + }, + ) + .setTimestamp(); + + if (missingRoles.length > 0) { + embed.addFields({ + name: '⚠️ Missing Roles', + value: `${missingRoles.length} role(s) no longer exist and could not be restored.`, + inline: false, + }); + } + + embed.addFields({ + name: 'Restored by', + value: `<@${interaction.user.id}>`, + inline: false, + }); + + await InteractionHelper.universalReply(interaction, { embeds: [embed] }); + + // Also notify in channel + await interaction.channel.send({ + embeds: [ + new EmbedBuilder() + .setColor(0x2ECC71) + .setDescription(`✅ <@${user.id}>'s roles have been restored by <@${interaction.user.id}>.`), + ], + }).catch(() => {}); + + logger.info('Roles restored', { + userId: interaction.user.id, + targetId: user.id, + guildId: interaction.guild.id, + restoredCount: restoredRoles.length, + }); + + } catch (error) { + logger.error('Restore command error:', error); + await handleInteractionError(interaction, error, { subtype: 'restore_failed' }); + } + }, +}; From 93566b6639f7afdef0889d60f7aaeb639657793d Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Wed, 24 Jun 2026 21:49:32 -0600 Subject: [PATCH 079/115] Update punish.js --- src/commands/Moderation/punish.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/commands/Moderation/punish.js b/src/commands/Moderation/punish.js index 9301a60f2..5c6af2bcb 100644 --- a/src/commands/Moderation/punish.js +++ b/src/commands/Moderation/punish.js @@ -14,9 +14,9 @@ import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/e import { getFromDb, setInDb } from '../../utils/database.js'; const PUNISHMENT_LOG_CHANNEL_ID = '1517145309015314442'; -const WARNING_ROLE_ID = '1519540353881866404'; -const MUTED_ROLE_ID = '1519537206182809743'; -const SUSPENSION_ROLE_ID = '1519537206182809743'; +const WARNING_ROLE_ID = '1480276327578996747'; +const MUTED_ROLE_ID = '1516865012554141801'; +const SUSPENSION_ROLE_ID = '1516865012554141801'; // Key to store saved roles per user const SAVED_ROLES_KEY = (guildId, userId) => `saved_roles_${guildId}_${userId}`; From 1fc6ea1bc12a22b747f67c88087ac5c16a2c5d57 Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Wed, 24 Jun 2026 21:57:22 -0600 Subject: [PATCH 080/115] Update punish.js --- src/commands/Moderation/punish.js | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/src/commands/Moderation/punish.js b/src/commands/Moderation/punish.js index 5c6af2bcb..02c305547 100644 --- a/src/commands/Moderation/punish.js +++ b/src/commands/Moderation/punish.js @@ -362,20 +362,8 @@ export default { const buttons = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`punish_reviewed_${caseCode}`) - .setLabel('✅ Reviewed by IA/HC') + .setLabel('✅ Reviewed by Management') .setStyle(ButtonStyle.Success), - new ButtonBuilder() - .setCustomId(`punish_processed_${caseCode}`) - .setLabel('Department Hub Processed') - .setStyle(ButtonStyle.Primary), - new ButtonBuilder() - .setCustomId(`punish_roster_${caseCode}`) - .setLabel('Roles & Roster Updated') - .setStyle(ButtonStyle.Danger), - new ButtonBuilder() - .setCustomId(`punish_rosterlink_${caseCode}`) - .setLabel('Roster') - .setStyle(ButtonStyle.Secondary), ); // Send to punishment log forum channel From 447191950c0efa11fc12ffcc86acb10e45b64d9d Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Fri, 26 Jun 2026 01:03:14 -0600 Subject: [PATCH 081/115] Added new offences --- src/commands/Moderation/offences.js | 128 ++++++++++++++++++++++++++++ src/commands/Moderation/punish.js | 76 ++++++++++++++++- 2 files changed, 200 insertions(+), 4 deletions(-) create mode 100644 src/commands/Moderation/offences.js diff --git a/src/commands/Moderation/offences.js b/src/commands/Moderation/offences.js new file mode 100644 index 000000000..267ed383a --- /dev/null +++ b/src/commands/Moderation/offences.js @@ -0,0 +1,128 @@ +import { + SlashCommandBuilder, + PermissionFlagsBits, + EmbedBuilder, + MessageFlags, +} from 'discord.js'; +import { logger } from '../../utils/logger.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; +import { getFromDb, setInDb } from '../../utils/database.js'; + +const OFFENCE_KEY = (guildId, userId) => `offences_${guildId}_${userId}`; +const OFFENCE_RESET_MS = 60 * 24 * 60 * 60 * 1000; // 60 days + +const ESCALATION_LADDER = [ + { level: 1, label: 'Verbal Warning or Written Warning' }, + { level: 2, label: 'Mute for 30 minutes' }, + { level: 3, label: 'Kick OR Extend Mute by 24 hours' }, + { level: 4, label: 'Temporary Mute for 3 days' }, + { level: 5, label: 'Permanent Mute' }, +]; + +export default { + data: new SlashCommandBuilder() + .setName('offences') + .setDescription('View or manage a member\'s offence history') + .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers) + + .addSubcommand(sub => + sub.setName('view') + .setDescription('View a member\'s offence history') + .addUserOption(opt => + opt.setName('member') + .setDescription('The member to check') + .setRequired(true) + ) + ) + + .addSubcommand(sub => + sub.setName('clear') + .setDescription('Clear a member\'s offence history') + .addUserOption(opt => + opt.setName('member') + .setDescription('The member to clear offences for') + .setRequired(true) + ) + ), + + category: 'moderation', + + async execute(interaction, config, client) { + try { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + const sub = interaction.options.getSubcommand(); + const user = interaction.options.getUser('member'); + + if (sub === 'view') { + const raw = await getFromDb(OFFENCE_KEY(interaction.guild.id, user.id), { offences: [] }); + const now = Date.now(); + const activeOffences = (raw.offences || []).filter(o => now - new Date(o.date).getTime() < OFFENCE_RESET_MS); + const expiredOffences = (raw.offences || []).filter(o => now - new Date(o.date).getTime() >= OFFENCE_RESET_MS); + + const nextLevel = Math.min(activeOffences.length + 1, ESCALATION_LADDER.length); + const nextEscalation = ESCALATION_LADDER[nextLevel - 1]; + + const embed = new EmbedBuilder() + .setColor(activeOffences.length === 0 ? 0x2ECC71 : activeOffences.length >= 4 ? 0xE74C3C : 0xF39C12) + .setTitle(`📋 Offence History — ${user.username}`) + .setThumbnail(user.displayAvatarURL({ dynamic: true })) + .addFields( + { name: 'Active Offences', value: `${activeOffences.length} / ${ESCALATION_LADDER.length}`, inline: true }, + { name: 'Expired Offences', value: `${expiredOffences.length}`, inline: true }, + { name: 'Resets After', value: '60 days of no offences', inline: true }, + ); + + if (activeOffences.length > 0) { + embed.addFields({ + name: 'Offence Log', + value: activeOffences.map((o, i) => + `**#${i + 1}** — ${o.type} • Case \`${o.caseCode}\` • ` + ).join('\n'), + inline: false, + }); + } else { + embed.addFields({ name: 'Offence Log', value: 'No active offences.', inline: false }); + } + + embed.addFields({ + name: 'Next Escalation', + value: activeOffences.length >= ESCALATION_LADDER.length + ? '⚠️ Max level reached — Perm Mute' + : `Level ${nextEscalation.level}: ${nextEscalation.label}`, + inline: false, + }); + + await InteractionHelper.universalReply(interaction, { embeds: [embed] }); + + } else if (sub === 'clear') { + if (!interaction.member.permissions.has(PermissionFlagsBits.ManageGuild)) { + throw new TitanBotError('No permission', ErrorTypes.PERMISSIONS, 'You need Manage Server permission to clear offences.', { subtype: 'missing_permission' }); + } + + await setInDb(OFFENCE_KEY(interaction.guild.id, user.id), { offences: [], lastOffence: null }); + + await InteractionHelper.universalReply(interaction, { + embeds: [ + new EmbedBuilder() + .setColor(0x2ECC71) + .setTitle('✅ Offences Cleared') + .setDescription(`All offences for <@${user.id}> have been cleared.`) + .setTimestamp(), + ], + }); + + logger.info('Offences cleared', { + userId: interaction.user.id, + targetId: user.id, + guildId: interaction.guild.id, + }); + } + + } catch (error) { + logger.error('Offences command error:', error); + await handleInteractionError(interaction, error, { subtype: 'offences_failed' }); + } + }, +}; diff --git a/src/commands/Moderation/punish.js b/src/commands/Moderation/punish.js index 02c305547..a2594b91a 100644 --- a/src/commands/Moderation/punish.js +++ b/src/commands/Moderation/punish.js @@ -14,13 +14,52 @@ import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/e import { getFromDb, setInDb } from '../../utils/database.js'; const PUNISHMENT_LOG_CHANNEL_ID = '1517145309015314442'; -const WARNING_ROLE_ID = '1480276327578996747'; -const MUTED_ROLE_ID = '1516865012554141801'; -const SUSPENSION_ROLE_ID = '1516865012554141801'; +const WARNING_ROLE_ID = '1519540353881866404'; +const MUTED_ROLE_ID = '1519537206182809743'; +const SUSPENSION_ROLE_ID = '1519537206182809743'; // Key to store saved roles per user const SAVED_ROLES_KEY = (guildId, userId) => `saved_roles_${guildId}_${userId}`; +// Escalation system +const OFFENCE_KEY = (guildId, userId) => `offences_${guildId}_${userId}`; +const OFFENCE_RESET_MS = 60 * 24 * 60 * 60 * 1000; // 60 days + +const ESCALATION_LADDER = [ + { level: 1, label: 'Verbal Warning or Written Warning', types: ['Verbal Warning', 'Written Warning'] }, + { level: 2, label: 'Mute for 30 minutes', types: ['Mute/Timeout'], duration: '30m' }, + { level: 3, label: 'Kick OR Extend Mute by 24 hours', types: ['Kick', 'Mute/Timeout'], duration: '24h' }, + { level: 4, label: 'Temporary Mute for 3 days', types: ['Mute/Timeout'], duration: '3d', auto: true }, + { level: 5, label: 'Permanent Mute', types: ['Permanent Ban'], auto: true }, +]; + +async function getOffenceData(guildId, userId) { + const data = await getFromDb(OFFENCE_KEY(guildId, userId), { offences: [], lastOffence: null }); + + // Filter out offences older than 60 days + const now = Date.now(); + data.offences = (data.offences || []).filter(o => now - new Date(o.date).getTime() < OFFENCE_RESET_MS); + + return data; +} + +async function recordOffence(guildId, userId, punishmentType, caseCode) { + const data = await getOffenceData(guildId, userId); + data.offences.push({ + type: punishmentType, + caseCode, + date: new Date().toISOString(), + }); + data.lastOffence = new Date().toISOString(); + await setInDb(OFFENCE_KEY(guildId, userId), data); + return data.offences.length; +} + +function getNextEscalation(offenceCount) { + const nextLevel = Math.min(offenceCount + 1, ESCALATION_LADDER.length); + return ESCALATION_LADDER[nextLevel - 1]; +} + async function saveAndRemoveRoles(member, guild) { try { // Get all roles except @everyone and bot-managed roles @@ -278,6 +317,11 @@ export default { } const caseCode = generateCaseCode(); + + // Record offence and get escalation info + const offenceCount = await recordOffence(interaction.guild.id, user.id, punishmentType, caseCode); + const nextEscalation = getNextEscalation(offenceCount); + const now = new Date(); const formattedDate = now.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', @@ -339,6 +383,12 @@ export default { }); } + // Add offence tracking info + embed.addFields( + { name: 'Offence Count', value: `#${offenceCount} (resets after 60 days)`, inline: true }, + { name: 'Next Escalation', value: offenceCount >= ESCALATION_LADDER.length ? '⚠️ Max level reached — Perm Mute' : `Level ${nextEscalation.level}: ${nextEscalation.label}`, inline: false }, + ); + if (cleanDuration) { embed.addFields( { name: 'Active For', value: formatDuration(cleanDuration), inline: true }, @@ -362,8 +412,20 @@ export default { const buttons = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`punish_reviewed_${caseCode}`) - .setLabel('✅ Reviewed by Management') + .setLabel('✅ Reviewed by IA/HC') .setStyle(ButtonStyle.Success), + new ButtonBuilder() + .setCustomId(`punish_processed_${caseCode}`) + .setLabel('Department Hub Processed') + .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId(`punish_roster_${caseCode}`) + .setLabel('Roles & Roster Updated') + .setStyle(ButtonStyle.Danger), + new ButtonBuilder() + .setCustomId(`punish_rosterlink_${caseCode}`) + .setLabel('Roster') + .setStyle(ButtonStyle.Secondary), ); // Send to punishment log forum channel @@ -431,6 +493,10 @@ export default { }, }); + const nextEscalationText = offenceCount >= ESCALATION_LADDER.length + ? '⚠️ This member is at the maximum offence level (Perm Mute).' + : `📋 Next escalation (offence #${offenceCount + 1}): **${nextEscalation.label}**`; + await InteractionHelper.universalReply(interaction, { embeds: [ new EmbedBuilder() @@ -440,7 +506,9 @@ export default { .addFields( { name: 'Member', value: `<@${user.id}>`, inline: true }, { name: 'Type', value: punishmentType, inline: true }, + { name: 'Offence Count', value: `#${offenceCount}`, inline: true }, { name: 'Reason', value: reason, inline: false }, + { name: 'Escalation Info', value: nextEscalationText, inline: false }, ) .setTimestamp(), ], From c57be7c88ccac62cf26241a3bcf63814d8403b47 Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Fri, 26 Jun 2026 01:10:03 -0600 Subject: [PATCH 082/115] . --- src/app.js | 2 + src/commands/Moderation/punish.js | 21 +++------ src/services/punishmentScheduler.js | 68 +++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 16 deletions(-) create mode 100644 src/services/punishmentScheduler.js diff --git a/src/app.js b/src/app.js index 6662de0d6..25f2dac31 100644 --- a/src/app.js +++ b/src/app.js @@ -10,6 +10,7 @@ import { getGuildConfig } from './services/guildConfig.js'; import { getServerCounters, saveServerCounters, updateCounter } from './services/serverstatsService.js'; import { logger, startupLog, shutdownLog } from './utils/logger.js'; import { checkBirthdays } from './services/birthdayService.js'; +import { processScheduledRemovals } from './services/punishmentScheduler.js'; import { checkGiveaways } from './services/giveawayService.js'; import { loadCommands, registerCommands as registerSlashCommands } from './handlers/commandLoader.js'; import pkg from '../package.json' with { type: 'json' }; @@ -244,6 +245,7 @@ class TitanBot extends Client { setupCronJobs() { cron.schedule('0 6 * * *', () => checkBirthdays(this)); + cron.schedule('* * * * *', () => processScheduledRemovals(this)); // Check scheduled punishments every minute cron.schedule('* * * * *', () => checkGiveaways(this)); cron.schedule('*/15 * * * *', () => this.updateAllCounters()); } diff --git a/src/commands/Moderation/punish.js b/src/commands/Moderation/punish.js index a2594b91a..f213db79d 100644 --- a/src/commands/Moderation/punish.js +++ b/src/commands/Moderation/punish.js @@ -12,6 +12,7 @@ import { logger } from '../../utils/logger.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; import { getFromDb, setInDb } from '../../utils/database.js'; +import { scheduleRoleRemoval } from '../../services/punishmentScheduler.js'; const PUNISHMENT_LOG_CHANNEL_ID = '1517145309015314442'; const WARNING_ROLE_ID = '1519540353881866404'; @@ -131,14 +132,8 @@ async function executePunishmentAction(member, punishmentType, durationStr, guil if (durationStr) { const ms = parseDuration(durationStr); if (ms) { - setTimeout(async () => { - try { - const freshMember = await guild.members.fetch(member.id).catch(() => null); - if (freshMember) { - await freshMember.roles.remove(MUTED_ROLE_ID).catch(() => {}); - } - } catch (err) {} - }, ms); + const removeAt = new Date(Date.now() + ms).toISOString(); + await scheduleRoleRemoval(guild.id, member.id, MUTED_ROLE_ID, removeAt, 'pending'); results.push(`⏰ Muted role will be removed after ${formatDuration(durationStr)}`); } } @@ -188,14 +183,8 @@ async function executePunishmentAction(member, punishmentType, durationStr, guil if (durationStr) { const ms = parseDuration(durationStr); if (ms) { - setTimeout(async () => { - try { - const freshMember = await guild.members.fetch(member.id).catch(() => null); - if (freshMember) { - await freshMember.roles.remove(SUSPENSION_ROLE_ID).catch(() => {}); - } - } catch (err) {} - }, ms); + const removeAt = new Date(Date.now() + ms).toISOString(); + await scheduleRoleRemoval(guild.id, member.id, SUSPENSION_ROLE_ID, removeAt, 'pending'); results.push(`⏰ Suspension role will be removed after ${formatDuration(durationStr)}`); } } diff --git a/src/services/punishmentScheduler.js b/src/services/punishmentScheduler.js new file mode 100644 index 000000000..89b7c1744 --- /dev/null +++ b/src/services/punishmentScheduler.js @@ -0,0 +1,68 @@ +// src/services/punishmentScheduler.js +// Handles scheduled role removals that persist through bot restarts + +import { logger } from '../utils/logger.js'; +import { getFromDb, setInDb } from '../utils/database.js'; + +const SCHEDULED_KEY = (guildId) => `scheduled_punishments_${guildId}`; + +export async function scheduleRoleRemoval(guildId, userId, roleId, removeAt, caseCode) { + const existing = await getFromDb(SCHEDULED_KEY(guildId), []); + + // Remove any existing schedule for same user+role + const filtered = existing.filter(s => !(s.userId === userId && s.roleId === roleId)); + + filtered.push({ + userId, + roleId, + removeAt, + caseCode, + scheduledAt: new Date().toISOString(), + }); + + await setInDb(SCHEDULED_KEY(guildId), filtered); + logger.info(`Scheduled role removal for user ${userId} in guild ${guildId} at ${removeAt}`); +} + +export async function processScheduledRemovals(client) { + try { + const now = Date.now(); + + for (const [guildId, guild] of client.guilds.cache) { + try { + const scheduled = await getFromDb(SCHEDULED_KEY(guildId), []); + if (!scheduled.length) continue; + + const remaining = []; + let changed = false; + + for (const task of scheduled) { + if (now >= new Date(task.removeAt).getTime()) { + // Time to remove the role + try { + const member = await guild.members.fetch(task.userId).catch(() => null); + if (member) { + await member.roles.remove(task.roleId).catch(() => {}); + logger.info(`Removed scheduled role ${task.roleId} from ${task.userId} in guild ${guildId} (case ${task.caseCode})`); + } + changed = true; + } catch (err) { + logger.error(`Failed to remove scheduled role for ${task.userId}:`, err); + remaining.push(task); // Keep it to retry + } + } else { + remaining.push(task); + } + } + + if (changed) { + await setInDb(SCHEDULED_KEY(guildId), remaining); + } + } catch (err) { + logger.error(`Error processing scheduled removals for guild ${guildId}:`, err); + } + } + } catch (err) { + logger.error('Error in processScheduledRemovals:', err); + } +} From f0f4af236576c2a231ca4d36a0f56bad4b405452 Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Fri, 26 Jun 2026 01:11:22 -0600 Subject: [PATCH 083/115] Update punish.js --- src/commands/Moderation/punish.js | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/commands/Moderation/punish.js b/src/commands/Moderation/punish.js index f213db79d..438b05eae 100644 --- a/src/commands/Moderation/punish.js +++ b/src/commands/Moderation/punish.js @@ -403,18 +403,6 @@ export default { .setCustomId(`punish_reviewed_${caseCode}`) .setLabel('✅ Reviewed by IA/HC') .setStyle(ButtonStyle.Success), - new ButtonBuilder() - .setCustomId(`punish_processed_${caseCode}`) - .setLabel('Department Hub Processed') - .setStyle(ButtonStyle.Primary), - new ButtonBuilder() - .setCustomId(`punish_roster_${caseCode}`) - .setLabel('Roles & Roster Updated') - .setStyle(ButtonStyle.Danger), - new ButtonBuilder() - .setCustomId(`punish_rosterlink_${caseCode}`) - .setLabel('Roster') - .setStyle(ButtonStyle.Secondary), ); // Send to punishment log forum channel From 16a57a66c078458bfc82a69c5f67856a6e9e6e88 Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Fri, 26 Jun 2026 01:16:07 -0600 Subject: [PATCH 084/115] Update punish.js --- src/commands/Moderation/punish.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/Moderation/punish.js b/src/commands/Moderation/punish.js index 438b05eae..17cb80982 100644 --- a/src/commands/Moderation/punish.js +++ b/src/commands/Moderation/punish.js @@ -401,7 +401,7 @@ export default { const buttons = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`punish_reviewed_${caseCode}`) - .setLabel('✅ Reviewed by IA/HC') + .setLabel('✅ Reviewed by Management') .setStyle(ButtonStyle.Success), ); From 2318de30ac716516687c9a0ffc77bbe196604f19 Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Fri, 26 Jun 2026 01:25:14 -0600 Subject: [PATCH 085/115] Update punish.js --- src/commands/Moderation/punish.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/commands/Moderation/punish.js b/src/commands/Moderation/punish.js index 17cb80982..09d6cb177 100644 --- a/src/commands/Moderation/punish.js +++ b/src/commands/Moderation/punish.js @@ -16,7 +16,7 @@ import { scheduleRoleRemoval } from '../../services/punishmentScheduler.js'; const PUNISHMENT_LOG_CHANNEL_ID = '1517145309015314442'; const WARNING_ROLE_ID = '1519540353881866404'; -const MUTED_ROLE_ID = '1519537206182809743'; +const MUTED_ROLE_ID = '1516865012554141801'; const SUSPENSION_ROLE_ID = '1519537206182809743'; // Key to store saved roles per user @@ -63,9 +63,10 @@ function getNextEscalation(offenceCount) { async function saveAndRemoveRoles(member, guild) { try { - // Get all roles except @everyone and bot-managed roles + // Get all roles except @everyone, bot-managed roles, and punishment roles + const EXCLUDED_ROLES = [MUTED_ROLE_ID, SUSPENSION_ROLE_ID, WARNING_ROLE_ID]; const rolesToSave = member.roles.cache - .filter(r => r.id !== guild.id && !r.managed) + .filter(r => r.id !== guild.id && !r.managed && !EXCLUDED_ROLES.includes(r.id)) .map(r => r.id); if (rolesToSave.length === 0) return; @@ -403,6 +404,7 @@ export default { .setCustomId(`punish_reviewed_${caseCode}`) .setLabel('✅ Reviewed by Management') .setStyle(ButtonStyle.Success), + ); // Send to punishment log forum channel @@ -496,4 +498,4 @@ export default { await handleInteractionError(interaction, error, { subtype: 'punish_failed' }); } }, -}; +}; \ No newline at end of file From 27728ab150ce3f0c5f94c7808a85c2b12aa5d8b1 Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Fri, 26 Jun 2026 01:47:00 -0600 Subject: [PATCH 086/115] Update ticket.js --- src/commands/Ticket/ticket.js | 215 ++++------------------------------ 1 file changed, 24 insertions(+), 191 deletions(-) diff --git a/src/commands/Ticket/ticket.js b/src/commands/Ticket/ticket.js index c40ce31e3..94c6e1ad5 100644 --- a/src/commands/Ticket/ticket.js +++ b/src/commands/Ticket/ticket.js @@ -1,85 +1,21 @@ import { getColor } from '../../config/bot.js'; -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags } from 'discord.js'; -import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getGuildConfig } from '../../services/guildConfig.js'; +import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, MessageFlags } from 'discord.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { logger } from '../../utils/logger.js'; import { handleInteractionError, replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; -import ticketConfig from './modules/ticket_dashboard.js'; import { handlePanelAdd, handlePanelList, handlePanelDelete } from './modules/ticket_panels.js'; export default { data: new SlashCommandBuilder() .setName("ticket") - .setDescription("Manages the server's ticket system.") + .setDescription("Manages the server's ticket panels.") .setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels) - // Existing setup subcommand - .addSubcommand((subcommand) => - subcommand - .setName("setup") - .setDescription("Sets up the ticket creation panel in a specified channel.") - .addChannelOption((option) => - option.setName("panel_channel") - .setDescription("The channel where the ticket panel will be sent.") - .addChannelTypes(ChannelType.GuildText) - .setRequired(true), - ) - .addStringOption((option) => - option.setName("panel_message") - .setDescription("The main message/description for the ticket panel.") - .setRequired(true), - ) - .addStringOption((option) => - option.setName("button_label") - .setDescription("The label for the ticket creation button (default: Create Ticket)") - .setRequired(false), - ) - .addChannelOption((option) => - option.setName("category") - .setDescription("The category where new tickets will be created (optional).") - .addChannelTypes(ChannelType.GuildCategory) - .setRequired(false), - ) - .addChannelOption((option) => - option.setName("closed_category") - .setDescription("The category where closed tickets will be moved (optional).") - .addChannelTypes(ChannelType.GuildCategory) - .setRequired(false), - ) - .addRoleOption((option) => - option.setName("staff_role") - .setDescription("The role that can access tickets (optional).") - .setRequired(false), - ) - .addIntegerOption((option) => - option.setName("max_tickets_per_user") - .setDescription("Maximum number of tickets a user can create (default: 3)") - .setMinValue(1) - .setMaxValue(10) - .setRequired(false), - ) - .addBooleanOption((option) => - option.setName("dm_on_close") - .setDescription("Send DM to user when their ticket is closed (default: true)") - .setRequired(false), - ), - ) - - // Existing dashboard subcommand - .addSubcommand((subcommand) => - subcommand - .setName("dashboard") - .setDescription("Open the interactive ticket system dashboard"), - ) - - // New panel subcommand group .addSubcommandGroup((group) => group .setName("panel") - .setDescription("Manage multiple ticket panels") + .setDescription("Manage ticket panels") - // panel add .addSubcommand((sub) => sub.setName("add") .setDescription("Create a new ticket panel in a channel") @@ -135,13 +71,11 @@ export default { ), ) - // panel list .addSubcommand((sub) => sub.setName("list") .setDescription("List all ticket panels for this server"), ) - // panel delete .addSubcommand((sub) => sub.setName("delete") .setDescription("Delete a ticket panel") @@ -151,6 +85,17 @@ export default { .setRequired(true), ), ), + ) + + .addSubcommand((sub) => + sub.setName("transcript") + .setDescription("Set the channel where ticket transcripts are sent") + .addChannelOption((opt) => + opt.setName("channel") + .setDescription("The channel to send transcripts to") + .addChannelTypes(ChannelType.GuildText) + .setRequired(true), + ), ), category: "ticket", @@ -161,141 +106,29 @@ export default { if (!deferred) return; if (!interaction.member.permissions.has(PermissionFlagsBits.ManageChannels)) { - logger.warn('Ticket command permission denied', { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'ticket' - }); return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need the `Manage Channels` permission for this action.' }); } const subcommand = interaction.options.getSubcommand(); const subcommandGroup = interaction.options.getSubcommandGroup(false); - // Handle panel subcommand group if (subcommandGroup === 'panel') { if (subcommand === 'add') return await handlePanelAdd(interaction, client); if (subcommand === 'list') return await handlePanelList(interaction, client); if (subcommand === 'delete') return await handlePanelDelete(interaction, client); - return; } - // Handle existing subcommands - if (subcommand === "dashboard") { - return ticketConfig.execute(interaction, config, client); - } - - if (subcommand === "setup") { - const existingConfig = await getGuildConfig(client, interaction.guildId); - if (existingConfig?.ticketPanelChannelId) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `This server already has a ticket system set up (panel in <#${existingConfig.ticketPanelChannelId}>).\n\nOnly one ticket system is supported per server. Use \`/ticket dashboard\` to edit or update the existing setup, or select **Delete System** from the dashboard to remove it and start fresh.\n\n💡 To create additional panels, use \`/ticket panel add\`.` }); - } - - const panelChannel = interaction.options.getChannel("panel_channel"); - const categoryChannel = interaction.options.getChannel("category"); - const closedCategoryChannel = interaction.options.getChannel("closed_category"); - const staffRole = interaction.options.getRole("staff_role"); - const panelMessage = interaction.options.getString("panel_message") || "Click the button below to create a support ticket."; - const buttonLabel = interaction.options.getString("button_label") || "Create Ticket"; - const maxTicketsPerUser = interaction.options.getInteger("max_tickets_per_user") || 3; - const dmOnClose = interaction.options.getBoolean("dm_on_close") !== false; - - const setupEmbed = createEmbed({ - title: "Support Tickets", - description: panelMessage, - color: getColor('info') + if (subcommand === 'transcript') { + const channel = interaction.options.getChannel('channel'); + const { getGuildConfig } = await import('../../services/guildConfig.js'); + const { getGuildConfigKey } = await import('../../utils/database.js'); + const guildConfig = await getGuildConfig(client, interaction.guildId); + guildConfig.ticketTranscriptChannelId = channel.id; + await client.db.set(getGuildConfigKey(interaction.guildId), guildConfig); + const { successEmbed } = await import('../../utils/embeds.js'); + return await InteractionHelper.safeEditReply(interaction, { + embeds: [successEmbed('✅ Transcript Channel Set', `Ticket transcripts will be sent to ${channel}.`)], }); - - const ticketButton = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId("create_ticket") - .setLabel(buttonLabel) - .setStyle(ButtonStyle.Primary) - .setEmoji("📩"), - ); - - try { - const sentPanel = await panelChannel.send({ - embeds: [setupEmbed], - components: [ticketButton], - }); - - if (client.db && interaction.guildId) { - const currentConfig = existingConfig; - currentConfig.ticketCategoryId = categoryChannel ? categoryChannel.id : null; - currentConfig.ticketClosedCategoryId = closedCategoryChannel ? closedCategoryChannel.id : null; - currentConfig.ticketStaffRoleId = staffRole ? staffRole.id : null; - currentConfig.ticketPanelChannelId = panelChannel.id; - currentConfig.ticketPanelMessageId = sentPanel?.id || null; - currentConfig.ticketPanelMessage = panelMessage; - currentConfig.ticketButtonLabel = buttonLabel; - currentConfig.maxTicketsPerUser = maxTicketsPerUser; - currentConfig.dmOnClose = dmOnClose; - - const { getGuildConfigKey } = await import('../../utils/database.js'); - const configKey = getGuildConfigKey(interaction.guildId); - await client.db.set(configKey, currentConfig); - logger.info('Ticket configuration saved', { - guildId: interaction.guildId, - categoryId: categoryChannel?.id, - closedCategoryId: closedCategoryChannel?.id, - staffRoleId: staffRole?.id, - maxTickets: maxTicketsPerUser, - dmOnClose: dmOnClose - }); - } - - let successMessage = `The ticket creation panel has been sent to ${panelChannel}.`; - if (categoryChannel) { - successMessage += ` New tickets will be created in the **${categoryChannel.name}** category.`; - } else { - successMessage += ' New tickets will be created in a new "Tickets" category.'; - } - if (closedCategoryChannel) { - successMessage += ` Closed tickets will be moved to **${closedCategoryChannel.name}**.`; - } - if (staffRole) { - successMessage += ` **${staffRole.name}** role will have access to tickets.`; - } - successMessage += `\n\n**Max Tickets Per User:** ${maxTicketsPerUser}\n**DM on Close:** ${dmOnClose ? 'Enabled' : 'Disabled'}`; - successMessage += `\n\n💡 To create additional panels, use \`/ticket panel add\`.`; - - await InteractionHelper.safeEditReply(interaction, { - embeds: [successEmbed("Ticket Panel Set Up", successMessage)], - }); - - logger.info('Ticket panel setup completed', { - userId: interaction.user.id, - userTag: interaction.user.tag, - guildId: interaction.guildId, - panelChannelId: panelChannel.id, - categoryId: categoryChannel?.id, - closedCategoryId: closedCategoryChannel?.id, - staffRoleId: staffRole?.id, - maxTickets: maxTicketsPerUser, - dmOnClose: dmOnClose, - commandName: 'ticket_setup' - }); - - } catch (error) { - logger.error('Ticket setup error', { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'ticket_setup' - }); - if (interaction.deferred || interaction.replied) { - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Could not send the ticket panel or save configuration. Check the bot\'s permissions and database connection.' }).catch(err => { - logger.error('Failed to send error reply', { error: err.message, guildId: interaction.guildId }); - }); - } else { - await handleInteractionError(interaction, error, { - commandName: 'ticket_setup', - source: 'ticket_setup_command' - }); - } - } } } catch (error) { logger.error('Error executing ticket command', { From b95d6ea93ea92a24b2d9c6cf1fe478fdbbc1074a Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Fri, 26 Jun 2026 02:10:39 -0600 Subject: [PATCH 087/115] Update ticket.js --- src/services/ticket.js | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/src/services/ticket.js b/src/services/ticket.js index 306117a74..65d40a213 100644 --- a/src/services/ticket.js +++ b/src/services/ticket.js @@ -401,7 +401,40 @@ components: [] ); await channel.send({ embeds: [closeEmbed], components: [controlRow] }); - + + // Generate and send transcript on close + try { + const guildConfig = await getGuildConfig(channel.client, channel.guild.id); + if (guildConfig.ticketTranscriptChannelId) { + const transcriptChannel = await channel.client.channels.fetch(guildConfig.ticketTranscriptChannelId).catch(() => null); + if (transcriptChannel?.isSendable()) { + const attachment = await generateTranscript(channel); + if (attachment) { + const transcriptEmbed = buildStandardLogEmbed({ + color: 0x3498db, + title: 'Ticket Transcript', + description: [ + formatLogLine('Ticket', `#${ticketData.id}`), + formatLogLine('Channel', `#${channel.name}`), + formatLogLine('Closed by', `<@${closer.id}>`), + formatLogLine('Generated', ``), + ].join('\n'), + footer: closer?.username + ? { text: `Closed by ${closer.username}`, iconURL: closer.displayAvatarURL?.() } + : undefined, + timestamp: true, + }); + await transcriptChannel.send({ embeds: [transcriptEmbed], files: [attachment] }); + logger.info('Transcript sent on ticket close', { channelId: channel.id, ticketNumber: ticketData.id }); + } + } else { + logger.warn('Transcript channel not sendable or not found on close', { channelId: channel.id }); + } + } + } catch (transcriptError) { + logger.error('Failed to send transcript on close:', { channelId: channel.id, error: transcriptError.message }); + } + await logTicketEvent({ client: channel.client, guildId: channel.guild.id, From 4eebd1c50eba5500ffdf2332f81ae9627461dbdf Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:51:42 -0600 Subject: [PATCH 088/115] Remove transcript generation and sending from deleteTicket Strips out the transcript generation and channel-send logic that ran during ticket deletion, so channels are deleted without attempting to produce or deliver a transcript file. Co-Authored-By: Claude Sonnet 4.6 --- src/services/ticket.js | 80 ------------------------------------------ 1 file changed, 80 deletions(-) diff --git a/src/services/ticket.js b/src/services/ticket.js index 65d40a213..5f52d4879 100644 --- a/src/services/ticket.js +++ b/src/services/ticket.js @@ -846,86 +846,6 @@ export async function deleteTicket(channel, deleter) { ticketId: ticketData.id }); - let attachment = null; - try { - attachment = await generateTranscript(channel); - if (attachment) { - logger.info('Transcript generated successfully, attempting to send', { - channelId: channel.id, - ticketNumber: ticketData.id - }); - } else { - logger.warn('Transcript generation returned null', { - channelId: channel.id, - ticketNumber: ticketData.id - }); - } - } catch (transcriptError) { - logger.error('Error during transcript generation', { - channelId: channel.id, - ticketNumber: ticketData.id, - error: transcriptError.message - }); - } - - if (attachment) { - try { - const guildConfig = await getGuildConfig(channel.client, channel.guild.id); - if (!guildConfig.ticketTranscriptChannelId) { - logger.warn('No transcript channel configured, skipping transcript send', { - channelId: channel.id, - ticketNumber: ticketData.id - }); - } else { - const transcriptChannel = await channel.client.channels.fetch(guildConfig.ticketTranscriptChannelId).catch(() => null); - - if (!transcriptChannel) { - logger.error('Could not fetch transcript channel', { - channelId: channel.id, - transcriptChannelId: guildConfig.ticketTranscriptChannelId - }); - } else if (!transcriptChannel.isSendable()) { - logger.error('Transcript channel exists but is not sendable', { - channelId: channel.id, - transcriptChannelId: transcriptChannel.id - }); - } else { - - const transcriptEmbed = buildStandardLogEmbed({ - color: 0x3498db, - title: 'Ticket Transcript', - description: [ - formatLogLine('Ticket', `#${ticketData.id}`), - formatLogLine('Channel', `#${channel.name}`), - formatLogLine('Generated', ``), - ].join('\n'), - footer: deleter?.username - ? { text: `Deleted by ${deleter.username}`, iconURL: deleter.displayAvatarURL?.() } - : undefined, - timestamp: true, - }); - - await transcriptChannel.send({ - embeds: [transcriptEmbed], - files: [attachment] - }); - - logger.info('✅ Transcript sent successfully', { - channelId: channel.id, - ticketNumber: ticketData.id, - transcriptChannelId: transcriptChannel.id - }); - } - } - } catch (sendError) { - logger.error('Failed to send transcript to channel:', { - channelId: channel.id, - ticketNumber: ticketData.id, - error: sendError.message - }); - } - } - try { await channel.delete('Ticket deleted permanently'); logger.info('✅ Channel deleted', { From b675ff558727eeca757ed29ce8757759b8decbbd Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Fri, 26 Jun 2026 11:09:11 -0600 Subject: [PATCH 089/115] Adding a website for the bot --- .claude/launch.json | 11 + .env.example | 7 + package-lock.json | 1413 ++++++++++++++++++++++++++- package.json | 7 +- src/app.js | 5 +- src/web/index.js | 38 + src/web/middleware/auth.js | 13 + src/web/public/assets/css/style.css | 455 +++++++++ src/web/public/assets/js/app.js | 50 + src/web/public/dashboard.html | 97 ++ src/web/public/guild.html | 376 +++++++ src/web/public/login.html | 37 + src/web/routes/api.js | 101 ++ src/web/routes/auth.js | 81 ++ 14 files changed, 2679 insertions(+), 12 deletions(-) create mode 100644 .claude/launch.json create mode 100644 src/web/index.js create mode 100644 src/web/middleware/auth.js create mode 100644 src/web/public/assets/css/style.css create mode 100644 src/web/public/assets/js/app.js create mode 100644 src/web/public/dashboard.html create mode 100644 src/web/public/guild.html create mode 100644 src/web/public/login.html create mode 100644 src/web/routes/api.js create mode 100644 src/web/routes/auth.js diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 000000000..1a68a6cc8 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "dashboard-preview", + "runtimeExecutable": "npx", + "runtimeArgs": ["serve", "-p", "5173", "src/web/public"], + "port": 5173 + } + ] +} diff --git a/.env.example b/.env.example index 0f426a175..d1ee0ef62 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,13 @@ WEB_HOST=0.0.0.0 PORT_RETRY_ATTEMPTS=5 CORS_ORIGIN=* +# Dashboard (OAuth2 — create an app at discord.com/developers) +# Set DASHBOARD_URL to where your bot is hosted, e.g. http://localhost:3000 +# Add /auth/callback as a Redirect URI in your Discord app +DISCORD_CLIENT_SECRET=your_discord_client_secret_here +DASHBOARD_URL=http://localhost:3000 +SESSION_SECRET=change_this_to_a_long_random_string + # 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" diff --git a/package-lock.json b/package-lock.json index edef65e76..ad8890739 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,11 +8,17 @@ "name": "titanbot-custom", "version": "2.1.0", "dependencies": { + "@discord-player/extractor": "^4.5.0", "@discordjs/rest": "^2.6.1", + "@discordjs/voice": "^0.17.0", "axios": "^1.15.2", + "discord-player": "^6.7.1", "discord.js": "^14.26.4", "dotenv": "^17.2.3", "express": "^5.1.0", + "express-session": "^1.19.0", + "ffmpeg-static": "^5.2.0", + "libsodium-wrappers": "^0.7.15", "node-cron": "^4.2.1", "pg": "^8.11.3", "winston": "^3.19.0", @@ -41,6 +47,66 @@ "kuler": "^2.0.0" } }, + "node_modules/@derhuerst/http-basic": { + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/@derhuerst/http-basic/-/http-basic-8.2.4.tgz", + "integrity": "sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw==", + "dependencies": { + "caseless": "^0.12.0", + "concat-stream": "^2.0.0", + "http-response-object": "^3.0.1", + "parse-cache-control": "^1.0.1" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@discord-player/equalizer": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@discord-player/equalizer/-/equalizer-0.2.3.tgz", + "integrity": "sha512-71UAepYMbHTg2QQLXQAgyuXYHrgAYpJDxjg9dRWfTUNf+zfOAlyJEiRRk/WFhQyGu6m23iLR/H/JxgF4AW8Csg==" + }, + "node_modules/@discord-player/extractor": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/@discord-player/extractor/-/extractor-4.5.1.tgz", + "integrity": "sha512-krsgdHD7sx4SWHKkIFDPcgYXR21EfleW0bjI6WvnJQbvTg9TZ4aYw77G6UVw/HaF7i3u3WuyGPZp1UqG0FRHaw==", + "dependencies": { + "file-type": "^16.5.4", + "genius-lyrics": "^4.4.6", + "isomorphic-unfetch": "^4.0.2", + "node-html-parser": "^6.1.4", + "reverbnation-scraper": "^2.0.0", + "soundcloud.ts": "^0.5.2", + "spotify-url-info": "^3.2.6", + "youtube-sr": "^4.3.9" + } + }, + "node_modules/@discord-player/ffmpeg": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@discord-player/ffmpeg/-/ffmpeg-0.1.0.tgz", + "integrity": "sha512-0kW6q4gMQN2B4Z4EzmUgXrKQSXXmyhjdZBBZ/6jSHZ9fh814oOu+JXP01VvtWHwTylI7qJHIctEWtSyjEubCJg==" + }, + "node_modules/@discord-player/opus": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@discord-player/opus/-/opus-0.1.2.tgz", + "integrity": "sha512-yF0m+pW7H9RCbRcgk/i6vv47tlWxaCHjp6/F0W4GXZMGZ0pcvZaxk8ic7aFPc3+IoDvrAHvWNomLq+JeFzdncA==" + }, + "node_modules/@discord-player/utils": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@discord-player/utils/-/utils-0.2.2.tgz", + "integrity": "sha512-UklWUT7BcZEkBgywM9Cmpo2nwj3SQ9Wmhu6ml1uy/YRQnY8IRdZEHD84T2kfjOg4LVZek0ej1VerIqq7a9PAHQ==", + "dependencies": { + "@discordjs/collection": "^1.1.0" + } + }, + "node_modules/@discord-player/utils/node_modules/@discordjs/collection": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", + "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", + "engines": { + "node": ">=16.11.0" + } + }, "node_modules/@discordjs/builders": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz", @@ -122,6 +188,30 @@ "url": "https://github.com/discordjs/discord.js?sponsor" } }, + "node_modules/@discordjs/voice": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@discordjs/voice/-/voice-0.17.0.tgz", + "integrity": "sha512-hArn9FF5ZYi1IkxdJEVnJi+OxlwLV0NJYWpKXsmNOojtGtAZHxmsELA+MZlu2KW1F/K1/nt7lFOfcMXNYweq9w==", + "deprecated": "This version uses deprecated encryption modes. Please use a newer version.", + "dependencies": { + "@types/ws": "^8.5.10", + "discord-api-types": "0.37.83", + "prism-media": "^1.3.5", + "tslib": "^2.6.2", + "ws": "^8.16.0" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/voice/node_modules/discord-api-types": { + "version": "0.37.83", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.37.83.tgz", + "integrity": "sha512-urGGYeWtWNYMKnYlZnOnDHm8fVRffQs3U0SpE8RHeiuLKb/u92APS8HoQnPTFbnXmY1vVnXjXO4dOxcAn3J+DA==" + }, "node_modules/@discordjs/ws": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", @@ -183,6 +273,11 @@ "text-hex": "1.0.x" } }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==" + }, "node_modules/@types/node": { "version": "25.9.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", @@ -213,6 +308,25 @@ "npm": ">=7.0.0" } }, + "node_modules/@web-scrobbler/metadata-filter": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@web-scrobbler/metadata-filter/-/metadata-filter-3.2.0.tgz", + "integrity": "sha512-K2Wkq9AOJkgj4Hk9g0flKnNWYkJy1GTPpHTgpNLU5OXaXgqPKLyrtb62M1cIxMN3ESH6XGvPKM92VEl/Gc3Rog==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -257,6 +371,25 @@ "proxy-from-env": "^2.1.0" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -280,6 +413,39 @@ "url": "https://opencollective.com/express" } }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -315,6 +481,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==" + }, "node_modules/color": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", @@ -368,6 +539,20 @@ "node": ">= 0.8" } }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -404,6 +589,40 @@ "node": ">=6.6.0" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "engines": { + "node": ">= 12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -441,6 +660,47 @@ "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.48.tgz", "integrity": "sha512-WFUE/2o0lBlLeCQonQ+Pu2RqHAqbytBJ2RlXR91gzk05InSS6k9ShzzLYoymrA4c2oRgRKGE7/VqQJNNdGWSxQ==" }, + "node_modules/discord-player": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/discord-player/-/discord-player-6.7.1.tgz", + "integrity": "sha512-ScQmChpZebpVzs+RMsSkCXSORUIXUR3aHsEssGZSLKrbWnregt3jVc39emftTt6EfarZ2TvTK262mXeAYPzpcQ==", + "dependencies": { + "@discord-player/equalizer": "^0.2.3", + "@discord-player/ffmpeg": "^0.1.0", + "@discord-player/utils": "^0.2.2", + "@web-scrobbler/metadata-filter": "^3.1.0", + "discord-voip": "^0.1.3", + "libsodium-wrappers": "^0.7.13" + }, + "funding": { + "url": "https://github.com/Androz2091/discord-player?sponsor=1" + }, + "peerDependencies": { + "@discord-player/extractor": "^4.5.0" + } + }, + "node_modules/discord-voip": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/discord-voip/-/discord-voip-0.1.3.tgz", + "integrity": "sha512-9DWY5/BLPXeldVwPr8/ggGjggTYOTw77aGQc3+4n5K54bRbbiJ9DUJc+mJzDiSLoHN3f286eRGACJYtrUu27xA==", + "dependencies": { + "@discord-player/ffmpeg": "^0.1.0", + "@discord-player/opus": "^0.1.2", + "@types/ws": "^8.5.5", + "discord-api-types": "^0.37.50", + "prism-media": "^1.3.5", + "tslib": "^2.6.1", + "ws": "^8.13.0" + }, + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/discord-voip/node_modules/discord-api-types": { + "version": "0.37.120", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.37.120.tgz", + "integrity": "sha512-7xpNK0EiWjjDFp2nAhHXezE4OUWm7s1zhc/UXXN6hnFFU8dfoPHgV0Hx0RPiCa3ILRpdeh152icc68DGCyXYIw==" + }, "node_modules/discord.js": { "version": "14.26.4", "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.26.4.tgz", @@ -484,6 +744,57 @@ "npm": ">=7.0.0" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ] + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dotenv": { "version": "17.4.2", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", @@ -526,6 +837,25 @@ "node": ">= 0.8" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "engines": { + "node": ">=6" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -580,6 +910,22 @@ "node": ">= 0.6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -622,6 +968,46 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-session": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz", + "integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==", + "dependencies": { + "cookie": "~0.7.2", + "cookie-signature": "~1.0.7", + "debug": "~2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.1.0", + "parseurl": "~1.3.3", + "safe-buffer": "~5.2.1", + "uid-safe": "~2.1.5" + }, + "engines": { + "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-session/node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==" + }, + "node_modules/express-session/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express-session/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -632,6 +1018,43 @@ "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==" }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/ffmpeg-static": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/ffmpeg-static/-/ffmpeg-static-5.3.0.tgz", + "integrity": "sha512-H+K6sW6TiIX6VGend0KQwthe+kaceeH/luE8dIZyOP35ik7ahYojDuqlTV1bOrtEwl01sy2HFNGQfi5IDJvotg==", + "hasInstallScript": true, + "dependencies": { + "@derhuerst/http-basic": "^8.2.0", + "env-paths": "^2.2.0", + "https-proxy-agent": "^5.0.0", + "progress": "^2.0.3" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/file-stream-rotator": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/file-stream-rotator/-/file-stream-rotator-0.6.1.tgz", @@ -640,6 +1063,22 @@ "moment": "^2.29.1" } }, + "node_modules/file-type": { + "version": "16.5.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-16.5.4.tgz", + "integrity": "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==", + "dependencies": { + "readable-web-to-node-stream": "^3.0.0", + "strtok3": "^6.2.4", + "token-types": "^4.1.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -718,6 +1157,17 @@ "node": ">= 0.6" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -742,6 +1192,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/genius-lyrics": { + "version": "4.4.7", + "resolved": "https://registry.npmjs.org/genius-lyrics/-/genius-lyrics-4.4.7.tgz", + "integrity": "sha512-cgO5nSeFqtLZAUyWB+8XWMRBIRzPUSUC42N3CoDGRgKX1anGAyDUhM6/RVIJXCNnQa6XHZHswKcKgHaRiyl+GQ==", + "dependencies": { + "node-html-parser": "^6.1.13", + "undici": "^6.11.1" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -824,6 +1283,19 @@ "node": ">= 0.4" } }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "bin": { + "he": "bin/he" + } + }, + "node_modules/himalaya": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/himalaya/-/himalaya-1.1.1.tgz", + "integrity": "sha512-mJLY5tErGWtsw8hO2fJ2vK4IpG6S1AIgVkduRo4FqFJhgI2H3XLzgemRemk45zcnFyxNNpOfrIDle2KcnJM0lA==" + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -843,6 +1315,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http-response-object": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/http-response-object/-/http-response-object-3.0.2.tgz", + "integrity": "sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==", + "dependencies": { + "@types/node": "^10.0.3" + } + }, + "node_modules/http-response-object/node_modules/@types/node": { + "version": "10.17.60", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz", + "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==" + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -870,6 +1355,25 @@ "url": "https://opencollective.com/express" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -899,11 +1403,33 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isomorphic-unfetch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/isomorphic-unfetch/-/isomorphic-unfetch-4.0.2.tgz", + "integrity": "sha512-1Yd+CF/7al18/N2BDbsLBcp6RO3tucSW+jcLq24dqdX5MNbCNTw1z4BsGsp4zNmjr/Izm2cs/cEqZPp4kvWSCA==", + "dependencies": { + "node-fetch": "^3.2.0", + "unfetch": "^5.0.0" + } + }, "node_modules/kuler": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==" }, + "node_modules/libsodium": { + "version": "0.7.16", + "resolved": "https://registry.npmjs.org/libsodium/-/libsodium-0.7.16.tgz", + "integrity": "sha512-3HrzSPuzm6Yt9aTYCDxYEG8x8/6C0+ag655Y7rhhWZM9PT4NpdnbqlzXhGZlDnkgR6MeSTnOt/VIyHLs9aSf+Q==" + }, + "node_modules/libsodium-wrappers": { + "version": "0.7.16", + "resolved": "https://registry.npmjs.org/libsodium-wrappers/-/libsodium-wrappers-0.7.16.tgz", + "integrity": "sha512-Gtr/WBx4dKjvRL1pvfwZqu7gO6AfrQ0u9vFL+kXihtHf6NfkROR8pjYWn98MFDI3jN19Ii1ZUfPR9afGiPyfHg==", + "dependencies": { + "libsodium": "^0.7.16" + } + }, "node_modules/lodash": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", @@ -1014,6 +1540,62 @@ "node": ">=6.0.0" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/node-html-parser": { + "version": "6.1.13", + "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-6.1.13.tgz", + "integrity": "sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==", + "dependencies": { + "css-select": "^5.1.0", + "he": "1.2.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/object-hash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", @@ -1044,6 +1626,14 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -1060,6 +1650,11 @@ "fn.name": "1.x.x" } }, + "node_modules/parse-cache-control": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parse-cache-control/-/parse-cache-control-1.0.1.tgz", + "integrity": "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg==" + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -1077,6 +1672,18 @@ "url": "https://opencollective.com/express" } }, + "node_modules/peek-readable": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-4.1.0.tgz", + "integrity": "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg==", + "engines": { + "node": ">=8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/pg": { "version": "8.21.0", "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz", @@ -1193,6 +1800,47 @@ "node": ">=0.10.0" } }, + "node_modules/prism-media": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/prism-media/-/prism-media-1.3.5.tgz", + "integrity": "sha512-IQdl0Q01m4LrkN1EGIE9lphov5Hy7WWlH6ulf5QdGePLlPas9p2mhgddTEHrlaXYjjFToM1/rWuwF37VF4taaA==", + "peerDependencies": { + "@discordjs/opus": ">=0.8.0 <1.0.0", + "ffmpeg-static": "^5.0.2 || ^4.2.7 || ^3.0.0 || ^2.4.0", + "node-opus": "^0.3.3", + "opusscript": "^0.0.8" + }, + "peerDependenciesMeta": { + "@discordjs/opus": { + "optional": true + }, + "ffmpeg-static": { + "optional": true + }, + "node-opus": { + "optional": true + }, + "opusscript": { + "optional": true + } + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -1227,6 +1875,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -1249,17 +1905,74 @@ "node": ">= 0.10" } }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readable-web-to-node-stream": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.4.tgz", + "integrity": "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw==", + "dependencies": { + "readable-stream": "^4.7.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/readable-web-to-node-stream/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/reverbnation-scraper": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/reverbnation-scraper/-/reverbnation-scraper-2.0.0.tgz", + "integrity": "sha512-t1Mew5QC9QEVEry5DXyagvci2O+TgXTGoMHbNoW5NRz6LTOzK/DLHUpnrQwloX8CVX5z1a802vwHM3YgUVOvKg==", + "dependencies": { + "node-fetch": "^2.6.0" + } + }, + "node_modules/reverbnation-scraper/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" + "whatwg-url": "^5.0.0" }, "engines": { - "node": ">= 6" + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } } }, "node_modules/router": { @@ -1425,6 +2138,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/soundcloud.ts": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/soundcloud.ts/-/soundcloud.ts-0.5.5.tgz", + "integrity": "sha512-bygjhC1w/w26Nk0Y+4D4cWSEJ1TdxLaE6+w4pCazFzPF+J4mzuB62ggWmFa7BiwnirzNf9lgPbjzrQYGege4Ew==", + "dependencies": { + "undici": "^6.17.0" + } + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -1433,6 +2154,26 @@ "node": ">= 10.x" } }, + "node_modules/spotify-uri": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/spotify-uri/-/spotify-uri-4.1.0.tgz", + "integrity": "sha512-SFpBt8pQqO7DOFBsdUjv3GxGZAKYP7UqcTflfE7h3YL1lynl/6Motq7NERoJJR8eF9kXQRSpcdMmV5ou84rbng==", + "engines": { + "node": ">= 16" + } + }, + "node_modules/spotify-url-info": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/spotify-url-info/-/spotify-url-info-3.3.1.tgz", + "integrity": "sha512-SR8ycXJVuwZCqNHcCX6mDF2lpqZTkl/CEGZsM6n4f9Vw6XI7zV6eCxZktDX4/CGSEAIj/owfcgq3xPpd2tUgiw==", + "dependencies": { + "himalaya": "~1.1.0", + "spotify-uri": "~4.1.0" + }, + "engines": { + "node": ">= 12" + } + }, "node_modules/stack-trace": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", @@ -1457,6 +2198,22 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/strtok3": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.3.0.tgz", + "integrity": "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==", + "dependencies": { + "@tokenizer/token": "^0.3.0", + "peek-readable": "^4.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/text-hex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", @@ -1470,6 +2227,27 @@ "node": ">=0.6" } }, + "node_modules/token-types": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-4.2.1.tgz", + "integrity": "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==", + "dependencies": { + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, "node_modules/triple-beam": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", @@ -1517,6 +2295,22 @@ "url": "https://opencollective.com/express" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==" + }, + "node_modules/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/undici": { "version": "6.24.1", "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", @@ -1530,6 +2324,11 @@ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==" }, + "node_modules/unfetch": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unfetch/-/unfetch-5.0.0.tgz", + "integrity": "sha512-3xM2c89siXg0nHvlmYsQ2zkLASvVMBisZm5lF3gFDqfF2xonNStDJyMpvaOBe0a1Edxmqrf2E0HBdmy9QyZaeg==" + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -1551,6 +2350,28 @@ "node": ">= 0.8" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "engines": { + "node": ">= 8" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/winston": { "version": "3.19.0", "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", @@ -1635,6 +2456,11 @@ "node": ">=0.4" } }, + "node_modules/youtube-sr": { + "version": "4.3.12", + "resolved": "https://registry.npmjs.org/youtube-sr/-/youtube-sr-4.3.12.tgz", + "integrity": "sha512-pAuh5FjCJ6q062lMw6ajr6j9IHKMXk/AZsEo/5xLhJcoO1gN/M2kxwfAwi3d9QLHMPg2DMdBL1DsuWbAsfc5HA==" + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", @@ -1660,6 +2486,62 @@ "kuler": "^2.0.0" } }, + "@derhuerst/http-basic": { + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/@derhuerst/http-basic/-/http-basic-8.2.4.tgz", + "integrity": "sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw==", + "requires": { + "caseless": "^0.12.0", + "concat-stream": "^2.0.0", + "http-response-object": "^3.0.1", + "parse-cache-control": "^1.0.1" + } + }, + "@discord-player/equalizer": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@discord-player/equalizer/-/equalizer-0.2.3.tgz", + "integrity": "sha512-71UAepYMbHTg2QQLXQAgyuXYHrgAYpJDxjg9dRWfTUNf+zfOAlyJEiRRk/WFhQyGu6m23iLR/H/JxgF4AW8Csg==" + }, + "@discord-player/extractor": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/@discord-player/extractor/-/extractor-4.5.1.tgz", + "integrity": "sha512-krsgdHD7sx4SWHKkIFDPcgYXR21EfleW0bjI6WvnJQbvTg9TZ4aYw77G6UVw/HaF7i3u3WuyGPZp1UqG0FRHaw==", + "requires": { + "file-type": "^16.5.4", + "genius-lyrics": "^4.4.6", + "isomorphic-unfetch": "^4.0.2", + "node-html-parser": "^6.1.4", + "reverbnation-scraper": "^2.0.0", + "soundcloud.ts": "^0.5.2", + "spotify-url-info": "^3.2.6", + "youtube-sr": "^4.3.9" + } + }, + "@discord-player/ffmpeg": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@discord-player/ffmpeg/-/ffmpeg-0.1.0.tgz", + "integrity": "sha512-0kW6q4gMQN2B4Z4EzmUgXrKQSXXmyhjdZBBZ/6jSHZ9fh814oOu+JXP01VvtWHwTylI7qJHIctEWtSyjEubCJg==" + }, + "@discord-player/opus": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@discord-player/opus/-/opus-0.1.2.tgz", + "integrity": "sha512-yF0m+pW7H9RCbRcgk/i6vv47tlWxaCHjp6/F0W4GXZMGZ0pcvZaxk8ic7aFPc3+IoDvrAHvWNomLq+JeFzdncA==" + }, + "@discord-player/utils": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@discord-player/utils/-/utils-0.2.2.tgz", + "integrity": "sha512-UklWUT7BcZEkBgywM9Cmpo2nwj3SQ9Wmhu6ml1uy/YRQnY8IRdZEHD84T2kfjOg4LVZek0ej1VerIqq7a9PAHQ==", + "requires": { + "@discordjs/collection": "^1.1.0" + }, + "dependencies": { + "@discordjs/collection": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", + "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==" + } + } + }, "@discordjs/builders": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz", @@ -1711,6 +2593,25 @@ "discord-api-types": "^0.38.33" } }, + "@discordjs/voice": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@discordjs/voice/-/voice-0.17.0.tgz", + "integrity": "sha512-hArn9FF5ZYi1IkxdJEVnJi+OxlwLV0NJYWpKXsmNOojtGtAZHxmsELA+MZlu2KW1F/K1/nt7lFOfcMXNYweq9w==", + "requires": { + "@types/ws": "^8.5.10", + "discord-api-types": "0.37.83", + "prism-media": "^1.3.5", + "tslib": "^2.6.2", + "ws": "^8.16.0" + }, + "dependencies": { + "discord-api-types": { + "version": "0.37.83", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.37.83.tgz", + "integrity": "sha512-urGGYeWtWNYMKnYlZnOnDHm8fVRffQs3U0SpE8RHeiuLKb/u92APS8HoQnPTFbnXmY1vVnXjXO4dOxcAn3J+DA==" + } + } + }, "@discordjs/ws": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", @@ -1755,6 +2656,11 @@ "text-hex": "1.0.x" } }, + "@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==" + }, "@types/node": { "version": "25.9.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", @@ -1781,6 +2687,19 @@ "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==" }, + "@web-scrobbler/metadata-filter": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@web-scrobbler/metadata-filter/-/metadata-filter-3.2.0.tgz", + "integrity": "sha512-K2Wkq9AOJkgj4Hk9g0flKnNWYkJy1GTPpHTgpNLU5OXaXgqPKLyrtb62M1cIxMN3ESH6XGvPKM92VEl/Gc3Rog==" + }, + "abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "requires": { + "event-target-shim": "^5.0.0" + } + }, "accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -1819,6 +2738,11 @@ "proxy-from-env": "^2.1.0" } }, + "base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" + }, "body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -1835,6 +2759,25 @@ "type-is": "^2.0.1" } }, + "boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" + }, + "buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "requires": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" + }, "bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1858,6 +2801,11 @@ "get-intrinsic": "^1.3.0" } }, + "caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==" + }, "color": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", @@ -1896,6 +2844,17 @@ "delayed-stream": "~1.0.0" } }, + "concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "requires": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -1916,6 +2875,28 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==" }, + "css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "requires": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + } + }, + "css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==" + }, + "data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==" + }, "debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1939,6 +2920,40 @@ "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.48.tgz", "integrity": "sha512-WFUE/2o0lBlLeCQonQ+Pu2RqHAqbytBJ2RlXR91gzk05InSS6k9ShzzLYoymrA4c2oRgRKGE7/VqQJNNdGWSxQ==" }, + "discord-player": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/discord-player/-/discord-player-6.7.1.tgz", + "integrity": "sha512-ScQmChpZebpVzs+RMsSkCXSORUIXUR3aHsEssGZSLKrbWnregt3jVc39emftTt6EfarZ2TvTK262mXeAYPzpcQ==", + "requires": { + "@discord-player/equalizer": "^0.2.3", + "@discord-player/ffmpeg": "^0.1.0", + "@discord-player/utils": "^0.2.2", + "@web-scrobbler/metadata-filter": "^3.1.0", + "discord-voip": "^0.1.3", + "libsodium-wrappers": "^0.7.13" + } + }, + "discord-voip": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/discord-voip/-/discord-voip-0.1.3.tgz", + "integrity": "sha512-9DWY5/BLPXeldVwPr8/ggGjggTYOTw77aGQc3+4n5K54bRbbiJ9DUJc+mJzDiSLoHN3f286eRGACJYtrUu27xA==", + "requires": { + "@discord-player/ffmpeg": "^0.1.0", + "@discord-player/opus": "^0.1.2", + "@types/ws": "^8.5.5", + "discord-api-types": "^0.37.50", + "prism-media": "^1.3.5", + "tslib": "^2.6.1", + "ws": "^8.13.0" + }, + "dependencies": { + "discord-api-types": { + "version": "0.37.120", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.37.120.tgz", + "integrity": "sha512-7xpNK0EiWjjDFp2nAhHXezE4OUWm7s1zhc/UXXN6hnFFU8dfoPHgV0Hx0RPiCa3ILRpdeh152icc68DGCyXYIw==" + } + } + }, "discord.js": { "version": "14.26.4", "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.26.4.tgz", @@ -1971,6 +2986,39 @@ } } }, + "dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "requires": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + } + }, + "domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==" + }, + "domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "requires": { + "domelementtype": "^2.3.0" + } + }, + "domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "requires": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + } + }, "dotenv": { "version": "17.4.2", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", @@ -2001,6 +3049,16 @@ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==" }, + "entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==" + }, + "env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==" + }, "es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -2040,6 +3098,16 @@ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==" }, + "event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==" + }, + "events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==" + }, "express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -2075,6 +3143,41 @@ "vary": "^1.1.2" } }, + "express-session": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz", + "integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==", + "requires": { + "cookie": "~0.7.2", + "cookie-signature": "~1.0.7", + "debug": "~2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.1.0", + "parseurl": "~1.3.3", + "safe-buffer": "~5.2.1", + "uid-safe": "~2.1.5" + }, + "dependencies": { + "cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==" + }, + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + } + } + }, "fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2085,6 +3188,26 @@ "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==" }, + "fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "requires": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + } + }, + "ffmpeg-static": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/ffmpeg-static/-/ffmpeg-static-5.3.0.tgz", + "integrity": "sha512-H+K6sW6TiIX6VGend0KQwthe+kaceeH/luE8dIZyOP35ik7ahYojDuqlTV1bOrtEwl01sy2HFNGQfi5IDJvotg==", + "requires": { + "@derhuerst/http-basic": "^8.2.0", + "env-paths": "^2.2.0", + "https-proxy-agent": "^5.0.0", + "progress": "^2.0.3" + } + }, "file-stream-rotator": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/file-stream-rotator/-/file-stream-rotator-0.6.1.tgz", @@ -2093,6 +3216,16 @@ "moment": "^2.29.1" } }, + "file-type": { + "version": "16.5.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-16.5.4.tgz", + "integrity": "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==", + "requires": { + "readable-web-to-node-stream": "^3.0.0", + "strtok3": "^6.2.4", + "token-types": "^4.1.1" + } + }, "finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -2143,6 +3276,14 @@ } } }, + "formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "requires": { + "fetch-blob": "^3.1.2" + } + }, "forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -2158,6 +3299,15 @@ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" }, + "genius-lyrics": { + "version": "4.4.7", + "resolved": "https://registry.npmjs.org/genius-lyrics/-/genius-lyrics-4.4.7.tgz", + "integrity": "sha512-cgO5nSeFqtLZAUyWB+8XWMRBIRzPUSUC42N3CoDGRgKX1anGAyDUhM6/RVIJXCNnQa6XHZHswKcKgHaRiyl+GQ==", + "requires": { + "node-html-parser": "^6.1.13", + "undici": "^6.11.1" + } + }, "get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -2210,6 +3360,16 @@ "function-bind": "^1.1.2" } }, + "he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==" + }, + "himalaya": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/himalaya/-/himalaya-1.1.1.tgz", + "integrity": "sha512-mJLY5tErGWtsw8hO2fJ2vK4IpG6S1AIgVkduRo4FqFJhgI2H3XLzgemRemk45zcnFyxNNpOfrIDle2KcnJM0lA==" + }, "http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -2222,6 +3382,21 @@ "toidentifier": "~1.0.1" } }, + "http-response-object": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/http-response-object/-/http-response-object-3.0.2.tgz", + "integrity": "sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==", + "requires": { + "@types/node": "^10.0.3" + }, + "dependencies": { + "@types/node": { + "version": "10.17.60", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz", + "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==" + } + } + }, "https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -2239,6 +3414,11 @@ "safer-buffer": ">= 2.1.2 < 3.0.0" } }, + "ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==" + }, "inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -2259,11 +3439,33 @@ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==" }, + "isomorphic-unfetch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/isomorphic-unfetch/-/isomorphic-unfetch-4.0.2.tgz", + "integrity": "sha512-1Yd+CF/7al18/N2BDbsLBcp6RO3tucSW+jcLq24dqdX5MNbCNTw1z4BsGsp4zNmjr/Izm2cs/cEqZPp4kvWSCA==", + "requires": { + "node-fetch": "^3.2.0", + "unfetch": "^5.0.0" + } + }, "kuler": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==" }, + "libsodium": { + "version": "0.7.16", + "resolved": "https://registry.npmjs.org/libsodium/-/libsodium-0.7.16.tgz", + "integrity": "sha512-3HrzSPuzm6Yt9aTYCDxYEG8x8/6C0+ag655Y7rhhWZM9PT4NpdnbqlzXhGZlDnkgR6MeSTnOt/VIyHLs9aSf+Q==" + }, + "libsodium-wrappers": { + "version": "0.7.16", + "resolved": "https://registry.npmjs.org/libsodium-wrappers/-/libsodium-wrappers-0.7.16.tgz", + "integrity": "sha512-Gtr/WBx4dKjvRL1pvfwZqu7gO6AfrQ0u9vFL+kXihtHf6NfkROR8pjYWn98MFDI3jN19Ii1ZUfPR9afGiPyfHg==", + "requires": { + "libsodium": "^0.7.16" + } + }, "lodash": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", @@ -2340,6 +3542,38 @@ "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", "integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==" }, + "node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==" + }, + "node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "requires": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + } + }, + "node-html-parser": { + "version": "6.1.13", + "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-6.1.13.tgz", + "integrity": "sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==", + "requires": { + "css-select": "^5.1.0", + "he": "1.2.0" + } + }, + "nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "requires": { + "boolbase": "^1.0.0" + } + }, "object-hash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", @@ -2358,6 +3592,11 @@ "ee-first": "1.1.1" } }, + "on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==" + }, "once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -2374,6 +3613,11 @@ "fn.name": "1.x.x" } }, + "parse-cache-control": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parse-cache-control/-/parse-cache-control-1.0.1.tgz", + "integrity": "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg==" + }, "parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -2384,6 +3628,11 @@ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==" }, + "peek-readable": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-4.1.0.tgz", + "integrity": "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg==" + }, "pg": { "version": "8.21.0", "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz", @@ -2467,6 +3716,22 @@ "xtend": "^4.0.0" } }, + "prism-media": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/prism-media/-/prism-media-1.3.5.tgz", + "integrity": "sha512-IQdl0Q01m4LrkN1EGIE9lphov5Hy7WWlH6ulf5QdGePLlPas9p2mhgddTEHrlaXYjjFToM1/rWuwF37VF4taaA==", + "requires": {} + }, + "process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==" + }, + "progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==" + }, "proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -2489,6 +3754,11 @@ "side-channel": "^1.1.0" } }, + "random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==" + }, "range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -2515,6 +3785,46 @@ "util-deprecate": "^1.0.1" } }, + "readable-web-to-node-stream": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.4.tgz", + "integrity": "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw==", + "requires": { + "readable-stream": "^4.7.0" + }, + "dependencies": { + "readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "requires": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + } + } + } + }, + "reverbnation-scraper": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/reverbnation-scraper/-/reverbnation-scraper-2.0.0.tgz", + "integrity": "sha512-t1Mew5QC9QEVEry5DXyagvci2O+TgXTGoMHbNoW5NRz6LTOzK/DLHUpnrQwloX8CVX5z1a802vwHM3YgUVOvKg==", + "requires": { + "node-fetch": "^2.6.0" + }, + "dependencies": { + "node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "requires": { + "whatwg-url": "^5.0.0" + } + } + } + }, "router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -2620,11 +3930,33 @@ "side-channel-map": "^1.0.1" } }, + "soundcloud.ts": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/soundcloud.ts/-/soundcloud.ts-0.5.5.tgz", + "integrity": "sha512-bygjhC1w/w26Nk0Y+4D4cWSEJ1TdxLaE6+w4pCazFzPF+J4mzuB62ggWmFa7BiwnirzNf9lgPbjzrQYGege4Ew==", + "requires": { + "undici": "^6.17.0" + } + }, "split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==" }, + "spotify-uri": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/spotify-uri/-/spotify-uri-4.1.0.tgz", + "integrity": "sha512-SFpBt8pQqO7DOFBsdUjv3GxGZAKYP7UqcTflfE7h3YL1lynl/6Motq7NERoJJR8eF9kXQRSpcdMmV5ou84rbng==" + }, + "spotify-url-info": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/spotify-url-info/-/spotify-url-info-3.3.1.tgz", + "integrity": "sha512-SR8ycXJVuwZCqNHcCX6mDF2lpqZTkl/CEGZsM6n4f9Vw6XI7zV6eCxZktDX4/CGSEAIj/owfcgq3xPpd2tUgiw==", + "requires": { + "himalaya": "~1.1.0", + "spotify-uri": "~4.1.0" + } + }, "stack-trace": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", @@ -2643,6 +3975,15 @@ "safe-buffer": "~5.2.0" } }, + "strtok3": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.3.0.tgz", + "integrity": "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==", + "requires": { + "@tokenizer/token": "^0.3.0", + "peek-readable": "^4.1.0" + } + }, "text-hex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", @@ -2653,6 +3994,20 @@ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" }, + "token-types": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-4.2.1.tgz", + "integrity": "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==", + "requires": { + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + } + }, + "tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, "triple-beam": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", @@ -2685,6 +4040,19 @@ } } }, + "typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==" + }, + "uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "requires": { + "random-bytes": "~1.0.0" + } + }, "undici": { "version": "6.24.1", "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", @@ -2695,6 +4063,11 @@ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==" }, + "unfetch": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unfetch/-/unfetch-5.0.0.tgz", + "integrity": "sha512-3xM2c89siXg0nHvlmYsQ2zkLASvVMBisZm5lF3gFDqfF2xonNStDJyMpvaOBe0a1Edxmqrf2E0HBdmy9QyZaeg==" + }, "unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -2710,6 +4083,25 @@ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" }, + "web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==" + }, + "webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "requires": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "winston": { "version": "3.19.0", "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", @@ -2765,6 +4157,11 @@ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==" }, + "youtube-sr": { + "version": "4.3.12", + "resolved": "https://registry.npmjs.org/youtube-sr/-/youtube-sr-4.3.12.tgz", + "integrity": "sha512-pAuh5FjCJ6q062lMw6ajr6j9IHKMXk/AZsEo/5xLhJcoO1gN/M2kxwfAwi3d9QLHMPg2DMdBL1DsuWbAsfc5HA==" + }, "zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/package.json b/package.json index 7ac3fc678..f80c3233e 100644 --- a/package.json +++ b/package.json @@ -15,14 +15,15 @@ "backup:drill": "node scripts/restore-drill.js" }, "dependencies": { + "@discord-player/extractor": "^4.5.0", "@discordjs/rest": "^2.6.1", "@discordjs/voice": "^0.17.0", - "@discord-player/extractor": "^4.5.0", "axios": "^1.15.2", - "discord.js": "^14.26.4", "discord-player": "^6.7.1", + "discord.js": "^14.26.4", "dotenv": "^17.2.3", "express": "^5.1.0", + "express-session": "^1.19.0", "ffmpeg-static": "^5.2.0", "libsodium-wrappers": "^0.7.15", "node-cron": "^4.2.1", @@ -34,4 +35,4 @@ "engines": { "node": ">=18.0.0" } -} \ No newline at end of file +} diff --git a/src/app.js b/src/app.js index 25f2dac31..77921dc2a 100644 --- a/src/app.js +++ b/src/app.js @@ -13,6 +13,7 @@ import { checkBirthdays } from './services/birthdayService.js'; import { processScheduledRemovals } from './services/punishmentScheduler.js'; import { checkGiveaways } from './services/giveawayService.js'; import { loadCommands, registerCommands as registerSlashCommands } from './handlers/commandLoader.js'; +import { mountDashboard } from './web/index.js'; import pkg from '../package.json' with { type: 'json' }; import { EXPECTED_SCHEMA_VERSION, EXPECTED_SCHEMA_LABEL } from './config/schemaVersion.js'; @@ -199,13 +200,15 @@ class TitanBot extends Client { }); app.get('/', (req, res) => { - res.status(200).json({ + res.status(200).json({ message: 'TitanBot System Online', version: pkg.version, timestamp: new Date().toISOString() }); }); + mountDashboard(app, this); + const startServer = (port, attempt = 0) => { let hasStartedListening = false; const server = app.listen(port, host, () => { diff --git a/src/web/index.js b/src/web/index.js new file mode 100644 index 000000000..2c4067c12 --- /dev/null +++ b/src/web/index.js @@ -0,0 +1,38 @@ +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import express from 'express'; +import session from 'express-session'; +import authRouter from './routes/auth.js'; +import { createApiRouter } from './routes/api.js'; +import { requireAuthPage } from './middleware/auth.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const publicDir = join(__dirname, 'public'); + +export function mountDashboard(app, client) { + app.use(session({ + secret: process.env.SESSION_SECRET || 'titanbot-dashboard-secret-change-me', + resave: false, + saveUninitialized: false, + cookie: { secure: process.env.NODE_ENV === 'production', maxAge: 86400000 }, + })); + + app.use(express.json()); + + app.use('/auth', authRouter); + app.use('/api', createApiRouter(client)); + app.use('/dashboard/assets', express.static(join(publicDir, 'assets'))); + + app.get('/dashboard/login', (req, res) => { + if (req.session?.user) return res.redirect('/dashboard'); + res.sendFile(join(publicDir, 'login.html')); + }); + + app.get('/dashboard', requireAuthPage, (req, res) => { + res.sendFile(join(publicDir, 'dashboard.html')); + }); + + app.get('/dashboard/guild/:id', requireAuthPage, (req, res) => { + res.sendFile(join(publicDir, 'guild.html')); + }); +} diff --git a/src/web/middleware/auth.js b/src/web/middleware/auth.js new file mode 100644 index 000000000..cfae422a9 --- /dev/null +++ b/src/web/middleware/auth.js @@ -0,0 +1,13 @@ +export function requireAuth(req, res, next) { + if (!req.session?.user) { + return res.status(401).json({ error: 'Unauthorized' }); + } + next(); +} + +export function requireAuthPage(req, res, next) { + if (!req.session?.user) { + return res.redirect('/dashboard/login'); + } + next(); +} diff --git a/src/web/public/assets/css/style.css b/src/web/public/assets/css/style.css new file mode 100644 index 000000000..491da5d1c --- /dev/null +++ b/src/web/public/assets/css/style.css @@ -0,0 +1,455 @@ +:root { + --bg: #0f1117; + --surface: #1a1d27; + --surface2: #22263a; + --border: #2e3248; + --accent: #5865f2; + --accent-hover: #4752c4; + --success: #57f287; + --danger: #ed4245; + --warning: #fee75c; + --text: #e2e5f0; + --text-muted: #8b8fa8; + --sidebar-w: 240px; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--bg); + color: var(--text); + min-height: 100vh; +} + +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } + +/* ── Login page ─────────────────────────────── */ +.login-page { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + background: radial-gradient(ellipse at top, #1e2240 0%, var(--bg) 70%); +} + +.login-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 16px; + padding: 48px 56px; + text-align: center; + max-width: 420px; + width: 100%; +} + +.login-card .logo { + width: 72px; + height: 72px; + background: var(--accent); + border-radius: 50%; + margin: 0 auto 24px; + display: flex; + align-items: center; + justify-content: center; + font-size: 32px; +} + +.login-card h1 { font-size: 24px; margin-bottom: 8px; } +.login-card p { color: var(--text-muted); margin-bottom: 32px; line-height: 1.5; } + +.btn { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 12px 24px; + border-radius: 8px; + border: none; + cursor: pointer; + font-size: 15px; + font-weight: 600; + transition: background 0.15s, transform 0.1s; + text-decoration: none; +} + +.btn:hover { transform: translateY(-1px); text-decoration: none; } +.btn:active { transform: translateY(0); } + +.btn-discord { background: #5865f2; color: #fff; } +.btn-discord:hover { background: #4752c4; } +.btn-primary { background: var(--accent); color: #fff; } +.btn-primary:hover { background: var(--accent-hover); } +.btn-danger { background: var(--danger); color: #fff; } +.btn-danger:hover { background: #c23235; } +.btn-ghost { background: var(--surface2); color: var(--text); border: 1px solid var(--border); } +.btn-ghost:hover { background: var(--border); } +.btn-sm { padding: 7px 14px; font-size: 13px; } + +.error-banner { + background: rgba(237,66,69,0.15); + border: 1px solid rgba(237,66,69,0.4); + color: #ff8b8d; + border-radius: 8px; + padding: 12px 16px; + margin-bottom: 20px; + font-size: 14px; +} + +/* ── App shell ──────────────────────────────── */ +.app { display: flex; min-height: 100vh; } + +.sidebar { + width: var(--sidebar-w); + background: var(--surface); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + position: fixed; + top: 0; left: 0; bottom: 0; +} + +.sidebar-header { + padding: 20px 16px; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + gap: 10px; +} + +.sidebar-logo { + width: 36px; height: 36px; + background: var(--accent); + border-radius: 10px; + display: flex; align-items: center; justify-content: center; + font-size: 18px; flex-shrink: 0; +} + +.sidebar-header h2 { font-size: 15px; font-weight: 700; } +.sidebar-header span { font-size: 11px; color: var(--text-muted); display: block; } + +.sidebar-nav { flex: 1; padding: 12px 8px; overflow-y: auto; } + +.nav-label { + font-size: 11px; + font-weight: 700; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.08em; + padding: 8px 8px 4px; +} + +.nav-item { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 10px; + border-radius: 8px; + color: var(--text-muted); + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background 0.1s, color 0.1s; + border: none; + background: none; + width: 100%; + text-align: left; +} + +.nav-item:hover { background: var(--surface2); color: var(--text); } +.nav-item.active { background: rgba(88,101,242,0.2); color: var(--accent); } +.nav-item .icon { font-size: 16px; width: 20px; text-align: center; } + +.sidebar-footer { + padding: 12px 8px; + border-top: 1px solid var(--border); +} + +.user-chip { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border-radius: 8px; + background: var(--surface2); +} + +.user-chip img { width: 32px; height: 32px; border-radius: 50%; } +.user-chip .name { font-size: 13px; font-weight: 600; flex: 1; } +.user-chip .logout { font-size: 12px; color: var(--text-muted); cursor: pointer; background: none; border: none; color: var(--text-muted); } +.user-chip .logout:hover { color: var(--danger); } + +/* ── Main content ───────────────────────────── */ +.main { margin-left: var(--sidebar-w); flex: 1; } + +.topbar { + background: var(--surface); + border-bottom: 1px solid var(--border); + padding: 16px 28px; + display: flex; + align-items: center; + gap: 14px; +} + +.topbar .back { color: var(--text-muted); font-size: 20px; cursor: pointer; background: none; border: none; } +.topbar .back:hover { color: var(--text); } +.topbar h1 { font-size: 18px; font-weight: 700; } +.topbar .guild-icon { width: 32px; height: 32px; border-radius: 8px; object-fit: cover; } +.topbar .guild-icon-placeholder { width: 32px; height: 32px; border-radius: 8px; background: var(--accent); display: inline-flex; align-items: center; justify-content: center; font-size: 14px; font-weight: 700; } + +.content { padding: 28px; max-width: 860px; } + +/* ── Guild selector (dashboard.html) ─────────── */ +.page-header { margin-bottom: 28px; } +.page-header h1 { font-size: 26px; font-weight: 800; margin-bottom: 6px; } +.page-header p { color: var(--text-muted); } + +.guild-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 16px; +} + +.guild-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 20px; + cursor: pointer; + transition: border-color 0.15s, transform 0.15s; + text-align: center; + text-decoration: none; + color: var(--text); + display: block; +} + +.guild-card:hover { border-color: var(--accent); transform: translateY(-2px); text-decoration: none; color: var(--text); } + +.guild-card img { width: 56px; height: 56px; border-radius: 14px; margin-bottom: 12px; } +.guild-card .guild-icon-lg { + width: 56px; height: 56px; border-radius: 14px; background: var(--accent); + margin: 0 auto 12px; display: flex; align-items: center; justify-content: center; + font-size: 22px; font-weight: 800; +} +.guild-card h3 { font-size: 14px; font-weight: 600; line-height: 1.3; word-break: break-word; } + +.empty-state { + text-align: center; + padding: 60px 20px; + color: var(--text-muted); +} +.empty-state .icon { font-size: 48px; margin-bottom: 16px; } +.empty-state h2 { font-size: 20px; color: var(--text); margin-bottom: 8px; } +.empty-state p { max-width: 360px; margin: 0 auto; line-height: 1.6; } + +/* ── Tabs ────────────────────────────────────── */ +.tabs { + display: flex; + gap: 4px; + border-bottom: 2px solid var(--border); + margin-bottom: 28px; +} + +.tab { + padding: 10px 18px; + font-size: 14px; + font-weight: 600; + color: var(--text-muted); + cursor: pointer; + border: none; + background: none; + border-bottom: 2px solid transparent; + margin-bottom: -2px; + transition: color 0.1s, border-color 0.1s; + display: flex; + align-items: center; + gap: 7px; +} + +.tab:hover { color: var(--text); } +.tab.active { color: var(--accent); border-bottom-color: var(--accent); } + +.tab-panel { display: none; } +.tab-panel.active { display: block; } + +/* ── Cards & forms ───────────────────────────── */ +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 24px; + margin-bottom: 20px; +} + +.card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 20px; + padding-bottom: 16px; + border-bottom: 1px solid var(--border); +} + +.card-header h2 { font-size: 15px; font-weight: 700; display: flex; align-items: center; gap: 8px; } +.card-header p { font-size: 13px; color: var(--text-muted); margin-top: 3px; } + +.form-group { margin-bottom: 18px; } +.form-group:last-child { margin-bottom: 0; } + +label { + display: block; + font-size: 13px; + font-weight: 600; + margin-bottom: 6px; + color: var(--text); +} + +.label-hint { + font-weight: 400; + color: var(--text-muted); + font-size: 12px; + margin-left: 6px; +} + +input[type="text"], +input[type="number"], +select, +textarea { + width: 100%; + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text); + padding: 9px 12px; + font-size: 14px; + font-family: inherit; + transition: border-color 0.15s; + outline: none; +} + +input[type="text"]:focus, +input[type="number"]:focus, +select:focus, +textarea:focus { border-color: var(--accent); } + +select option { background: var(--surface2); } +textarea { resize: vertical; min-height: 80px; } + +.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } + +/* Toggle */ +.toggle-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 0; + border-bottom: 1px solid var(--border); +} +.toggle-row:last-child { border-bottom: none; padding-bottom: 0; } +.toggle-row:first-child { padding-top: 0; } + +.toggle-info h3 { font-size: 14px; font-weight: 600; margin-bottom: 2px; } +.toggle-info p { font-size: 12px; color: var(--text-muted); } + +.toggle { + position: relative; + width: 44px; + height: 24px; + flex-shrink: 0; +} +.toggle input { opacity: 0; width: 0; height: 0; } +.toggle-slider { + position: absolute; + inset: 0; + background: var(--border); + border-radius: 24px; + cursor: pointer; + transition: background 0.2s; +} +.toggle-slider:before { + content: ''; + position: absolute; + width: 18px; height: 18px; + left: 3px; top: 3px; + background: #fff; + border-radius: 50%; + transition: transform 0.2s; +} +.toggle input:checked + .toggle-slider { background: var(--accent); } +.toggle input:checked + .toggle-slider:before { transform: translateX(20px); } + +/* Save bar */ +.save-bar { + position: sticky; + bottom: 0; + background: var(--surface); + border-top: 1px solid var(--border); + padding: 16px 28px; + display: flex; + align-items: center; + justify-content: space-between; + margin-left: calc(-1 * var(--sidebar-w)); + margin-right: 0; +} + +.save-status { font-size: 13px; color: var(--text-muted); display: flex; align-items: center; gap: 8px; } +.save-status.success { color: var(--success); } +.save-status.error { color: var(--danger); } + +/* Loading */ +.spinner { + width: 20px; height: 20px; + border: 2px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.6s linear infinite; + display: inline-block; +} +@keyframes spin { to { transform: rotate(360deg); } } + +.skeleton { + background: linear-gradient(90deg, var(--surface2) 25%, var(--border) 50%, var(--surface2) 75%); + background-size: 200% 100%; + animation: shimmer 1.2s infinite; + border-radius: 6px; + height: 16px; +} +@keyframes shimmer { to { background-position: -200% 0; } } + +.loading-overlay { + display: flex; + align-items: center; + justify-content: center; + padding: 60px; + flex-direction: column; + gap: 14px; + color: var(--text-muted); +} + +/* Toast */ +.toast-container { + position: fixed; + bottom: 24px; + right: 24px; + display: flex; + flex-direction: column; + gap: 10px; + z-index: 1000; +} + +.toast { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + padding: 14px 18px; + font-size: 14px; + display: flex; + align-items: center; + gap: 10px; + box-shadow: 0 4px 20px rgba(0,0,0,0.4); + animation: slide-in 0.2s ease; + min-width: 260px; +} +.toast.success { border-left: 3px solid var(--success); } +.toast.error { border-left: 3px solid var(--danger); } +@keyframes slide-in { from { opacity: 0; transform: translateX(20px); } } diff --git a/src/web/public/assets/js/app.js b/src/web/public/assets/js/app.js new file mode 100644 index 000000000..8d88ae8e6 --- /dev/null +++ b/src/web/public/assets/js/app.js @@ -0,0 +1,50 @@ +// Shared utilities for all dashboard pages + +async function api(url, options = {}) { + try { + const res = await fetch(url, { + headers: { 'Content-Type': 'application/json', ...options.headers }, + ...options, + body: options.body ? JSON.stringify(options.body) : undefined, + }); + if (res.status === 401) { location.href = '/dashboard/login'; return null; } + if (!res.ok) throw new Error(await res.text()); + return await res.json(); + } catch (err) { + console.error(url, err); + toast(err.message || 'Request failed', 'error'); + return null; + } +} + +function escHtml(str) { + return String(str ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function toast(message, type = 'success') { + const container = document.getElementById('toasts'); + if (!container) return; + const el = document.createElement('div'); + el.className = `toast ${type}`; + el.innerHTML = `${type === 'success' ? '✅' : '❌'} ${escHtml(message)}`; + container.appendChild(el); + setTimeout(() => el.remove(), 4000); +} + +function buildSelect(options, value, placeholder = 'None / Not set') { + const opts = [``, + ...options.map(o => ``) + ]; + return opts.join(''); +} + +function channelOptions(channels, types) { + return channels.filter(c => types.includes(c.type)); +} + +// Discord channel types +const CH = { TEXT: 0, CATEGORY: 4, ANNOUNCEMENT: 5 }; diff --git a/src/web/public/dashboard.html b/src/web/public/dashboard.html new file mode 100644 index 000000000..edc6fb9a2 --- /dev/null +++ b/src/web/public/dashboard.html @@ -0,0 +1,97 @@ + + + + + + TitanBot Dashboard + + + +
+ + +
+
+ + +
+
+
+ Loading your servers… +
+
+
+
+
+ +
+ + + + + diff --git a/src/web/public/guild.html b/src/web/public/guild.html new file mode 100644 index 000000000..3a709aacb --- /dev/null +++ b/src/web/public/guild.html @@ -0,0 +1,376 @@ + + + + + + TitanBot — Server Config + + + +
+ + +
+
+ + +

Loading…

+
+ +
+
+
+ Loading server settings… +
+
+ + +
+
+ +
+ + + + diff --git a/src/web/public/login.html b/src/web/public/login.html new file mode 100644 index 000000000..1b9cef89c --- /dev/null +++ b/src/web/public/login.html @@ -0,0 +1,37 @@ + + + + + + SyneBot Dashboard — Login + + + + + + + diff --git a/src/web/routes/api.js b/src/web/routes/api.js new file mode 100644 index 000000000..61412b850 --- /dev/null +++ b/src/web/routes/api.js @@ -0,0 +1,101 @@ +import { Router } from 'express'; +import { requireAuth } from '../middleware/auth.js'; +import { getGuildConfig, updateGuildConfig } from '../../services/guildConfig.js'; + +const MANAGE_GUILD = 0x20; + +const ALLOWED_CONFIG_KEYS = new Set([ + 'prefix', 'modRole', 'adminRole', + 'welcomeChannel', 'welcomeMessage', 'autoRole', + 'dmOnClose', + 'ticketPanelChannelId', 'ticketStaffRoleId', 'ticketCategoryId', + 'ticketClosedCategoryId', 'ticketPanelMessage', 'ticketButtonLabel', + 'maxTicketsPerUser', 'ticketLogsChannelId', 'ticketTranscriptChannelId', + 'logging', +]); + +export function createApiRouter(client) { + const router = Router(); + router.use(requireAuth); + + router.get('/me', (req, res) => { + const { id, username, avatar } = req.session.user; + res.json({ id, username, avatar }); + }); + + router.get('/guilds', (req, res) => { + const userGuilds = req.session.guilds || []; + const managedGuilds = userGuilds.filter(g => (BigInt(g.permissions) & BigInt(MANAGE_GUILD)) === BigInt(MANAGE_GUILD)); + const botGuildIds = new Set(client.guilds.cache.keys()); + + const guilds = managedGuilds + .filter(g => botGuildIds.has(g.id)) + .map(g => ({ + id: g.id, + name: g.name, + icon: g.icon, + })); + + res.json(guilds); + }); + + router.get('/guilds/:id', async (req, res) => { + const { id } = req.params; + if (!canManageGuild(req, id)) return res.status(403).json({ error: 'Forbidden' }); + + const guild = client.guilds.cache.get(id); + if (!guild) return res.status(404).json({ error: 'Bot is not in this guild' }); + + const channels = guild.channels.cache + .filter(c => c.type !== undefined) + .map(c => ({ id: c.id, name: c.name, type: c.type })) + .sort((a, b) => a.name.localeCompare(b.name)); + + const roles = guild.roles.cache + .filter(r => r.id !== guild.id) + .map(r => ({ id: r.id, name: r.name, color: r.hexColor })) + .sort((a, b) => b.rawPosition - a.rawPosition); + + res.json({ id: guild.id, name: guild.name, icon: guild.iconURL(), channels, roles }); + }); + + router.get('/guilds/:id/config', async (req, res) => { + const { id } = req.params; + if (!canManageGuild(req, id)) return res.status(403).json({ error: 'Forbidden' }); + + try { + const config = await getGuildConfig(client, id); + res.json(config); + } catch (err) { + res.status(500).json({ error: 'Failed to load config' }); + } + }); + + router.patch('/guilds/:id/config', async (req, res) => { + const { id } = req.params; + if (!canManageGuild(req, id)) return res.status(403).json({ error: 'Forbidden' }); + + const updates = {}; + for (const [key, value] of Object.entries(req.body)) { + if (ALLOWED_CONFIG_KEYS.has(key)) { + updates[key] = value; + } + } + + try { + const updated = await updateGuildConfig(client, id, updates); + res.json(updated); + } catch (err) { + res.status(500).json({ error: 'Failed to save config' }); + } + }); + + return router; +} + +function canManageGuild(req, guildId) { + const userGuilds = req.session.guilds || []; + const guild = userGuilds.find(g => g.id === guildId); + if (!guild) return false; + return (BigInt(guild.permissions) & BigInt(MANAGE_GUILD)) === BigInt(MANAGE_GUILD); +} diff --git a/src/web/routes/auth.js b/src/web/routes/auth.js new file mode 100644 index 000000000..a7211356a --- /dev/null +++ b/src/web/routes/auth.js @@ -0,0 +1,81 @@ +import { Router } from 'express'; + +const router = Router(); + +const DISCORD_API = 'https://discord.com/api/v10'; + +function getOAuthURL(state) { + const params = new URLSearchParams({ + client_id: process.env.CLIENT_ID, + redirect_uri: process.env.DASHBOARD_URL + '/auth/callback', + response_type: 'code', + scope: 'identify guilds', + state, + }); + return `https://discord.com/api/oauth2/authorize?${params}`; +} + +router.get('/login', (req, res) => { + const state = Math.random().toString(36).slice(2); + req.session.oauthState = state; + res.redirect(getOAuthURL(state)); +}); + +router.get('/callback', async (req, res) => { + const { code, state } = req.query; + + if (!code || state !== req.session.oauthState) { + return res.redirect('/dashboard/login?error=invalid_state'); + } + + delete req.session.oauthState; + + try { + const tokenRes = await fetch(`${DISCORD_API}/oauth2/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: process.env.CLIENT_ID, + client_secret: process.env.DISCORD_CLIENT_SECRET, + grant_type: 'authorization_code', + code, + redirect_uri: process.env.DASHBOARD_URL + '/auth/callback', + }), + }); + + if (!tokenRes.ok) throw new Error('Token exchange failed'); + const tokens = await tokenRes.json(); + + const [userRes, guildsRes] = await Promise.all([ + fetch(`${DISCORD_API}/users/@me`, { + headers: { Authorization: `Bearer ${tokens.access_token}` }, + }), + fetch(`${DISCORD_API}/users/@me/guilds`, { + headers: { Authorization: `Bearer ${tokens.access_token}` }, + }), + ]); + + const user = await userRes.json(); + const guilds = await guildsRes.json(); + + req.session.user = { + id: user.id, + username: user.username, + avatar: user.avatar, + accessToken: tokens.access_token, + }; + req.session.guilds = guilds; + + res.redirect('/dashboard'); + } catch (err) { + console.error('OAuth error:', err); + res.redirect('/dashboard/login?error=auth_failed'); + } +}); + +router.post('/logout', (req, res) => { + req.session.destroy(); + res.redirect('/dashboard/login'); +}); + +export default router; From c769bb964d354b276eef6f71bdb29a3b2f76ab25 Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:05:30 -0600 Subject: [PATCH 090/115] Use PostgreSQL session store to fix MemoryStore warning Replaces the default in-memory session store with connect-pg-simple, using the bot's existing PostgreSQL pool. Falls back to memory store if the pool isn't available (e.g. degraded mode). Co-Authored-By: Claude Sonnet 4.6 --- package-lock.json | 20 ++++++++++++++++++++ package.json | 1 + src/web/index.js | 9 +++++++++ 3 files changed, 30 insertions(+) diff --git a/package-lock.json b/package-lock.json index ad8890739..de2f5376f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@discordjs/rest": "^2.6.1", "@discordjs/voice": "^0.17.0", "axios": "^1.15.2", + "connect-pg-simple": "^10.0.0", "discord-player": "^6.7.1", "discord.js": "^14.26.4", "dotenv": "^17.2.3", @@ -553,6 +554,17 @@ "typedarray": "^0.0.6" } }, + "node_modules/connect-pg-simple": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/connect-pg-simple/-/connect-pg-simple-10.0.0.tgz", + "integrity": "sha512-pBGVazlqiMrackzCr0eKhn4LO5trJXsOX0nQoey9wCOayh80MYtThCbq8eoLsjpiWgiok/h+1/uti9/2/Una8A==", + "dependencies": { + "pg": "^8.12.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=22.0.0" + } + }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -2855,6 +2867,14 @@ "typedarray": "^0.0.6" } }, + "connect-pg-simple": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/connect-pg-simple/-/connect-pg-simple-10.0.0.tgz", + "integrity": "sha512-pBGVazlqiMrackzCr0eKhn4LO5trJXsOX0nQoey9wCOayh80MYtThCbq8eoLsjpiWgiok/h+1/uti9/2/Una8A==", + "requires": { + "pg": "^8.12.0" + } + }, "content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", diff --git a/package.json b/package.json index f80c3233e..933c2945a 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@discordjs/rest": "^2.6.1", "@discordjs/voice": "^0.17.0", "axios": "^1.15.2", + "connect-pg-simple": "^10.0.0", "discord-player": "^6.7.1", "discord.js": "^14.26.4", "dotenv": "^17.2.3", diff --git a/src/web/index.js b/src/web/index.js index 2c4067c12..ea2d3bd85 100644 --- a/src/web/index.js +++ b/src/web/index.js @@ -2,15 +2,24 @@ import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; import express from 'express'; import session from 'express-session'; +import connectPgSimple from 'connect-pg-simple'; import authRouter from './routes/auth.js'; import { createApiRouter } from './routes/api.js'; import { requireAuthPage } from './middleware/auth.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const publicDir = join(__dirname, 'public'); +const PgSession = connectPgSimple(session); export function mountDashboard(app, client) { + const pool = client.db?.db?.pool; + + const store = pool + ? new PgSession({ pool, createTableIfMissing: true }) + : undefined; + app.use(session({ + store, secret: process.env.SESSION_SECRET || 'titanbot-dashboard-secret-change-me', resave: false, saveUninitialized: false, From 4c0fdfff90c44b90d842070497cf28d37fdbe722 Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:07:55 -0600 Subject: [PATCH 091/115] Fix pg session store pool conflict Use a separate connection string for connect-pg-simple instead of sharing the bot's pool, which caused "cannot use pool after end" errors. Co-Authored-By: Claude Sonnet 4.6 --- src/web/index.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/web/index.js b/src/web/index.js index ea2d3bd85..a3bd59458 100644 --- a/src/web/index.js +++ b/src/web/index.js @@ -12,10 +12,10 @@ const publicDir = join(__dirname, 'public'); const PgSession = connectPgSimple(session); export function mountDashboard(app, client) { - const pool = client.db?.db?.pool; + const conString = process.env.POSTGRES_URL || process.env.DATABASE_URL; - const store = pool - ? new PgSession({ pool, createTableIfMissing: true }) + const store = conString + ? new PgSession({ conString, createTableIfMissing: true }) : undefined; app.use(session({ From be44a4b23f35972c6f24c66c41a58044311050df Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:17:52 -0600 Subject: [PATCH 092/115] Add auth debug endpoint --- src/web/routes/auth.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/web/routes/auth.js b/src/web/routes/auth.js index a7211356a..8cb742d7a 100644 --- a/src/web/routes/auth.js +++ b/src/web/routes/auth.js @@ -15,6 +15,15 @@ function getOAuthURL(state) { return `https://discord.com/api/oauth2/authorize?${params}`; } +router.get('/debug', (req, res) => { + res.json({ + CLIENT_ID: process.env.CLIENT_ID, + DASHBOARD_URL: process.env.DASHBOARD_URL, + redirect_uri: process.env.DASHBOARD_URL + '/auth/callback', + full_url: getOAuthURL('test'), + }); +}); + router.get('/login', (req, res) => { const state = Math.random().toString(36).slice(2); req.session.oauthState = state; From d7597bebb8d5324f141bcdd8e3434759967f4e7c Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:22:31 -0600 Subject: [PATCH 093/115] Save session before OAuth redirect to fix invalid state error Co-Authored-By: Claude Sonnet 4.6 --- src/web/routes/auth.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/web/routes/auth.js b/src/web/routes/auth.js index 8cb742d7a..a1522d4aa 100644 --- a/src/web/routes/auth.js +++ b/src/web/routes/auth.js @@ -27,7 +27,7 @@ router.get('/debug', (req, res) => { router.get('/login', (req, res) => { const state = Math.random().toString(36).slice(2); req.session.oauthState = state; - res.redirect(getOAuthURL(state)); + req.session.save(() => res.redirect(getOAuthURL(state))); }); router.get('/callback', async (req, res) => { From 18caf1353a0dfc8167dd686d1a4e2820a87249d9 Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:24:25 -0600 Subject: [PATCH 094/115] Trust proxy on Railway to fix session cookie over HTTPS Co-Authored-By: Claude Sonnet 4.6 --- src/web/index.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/web/index.js b/src/web/index.js index a3bd59458..2d12da33e 100644 --- a/src/web/index.js +++ b/src/web/index.js @@ -12,6 +12,8 @@ const publicDir = join(__dirname, 'public'); const PgSession = connectPgSimple(session); export function mountDashboard(app, client) { + app.set('trust proxy', 1); + const conString = process.env.POSTGRES_URL || process.env.DATABASE_URL; const store = conString From b25a6ae406a6f217e277dde665613d1e956895ba Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:26:06 -0600 Subject: [PATCH 095/115] Remove connect-pg-simple to fix pool conflict errors Memory store is sufficient for a single Railway instance. Co-Authored-By: Claude Sonnet 4.6 --- src/web/index.js | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/web/index.js b/src/web/index.js index 2d12da33e..a3405a742 100644 --- a/src/web/index.js +++ b/src/web/index.js @@ -2,26 +2,17 @@ import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; import express from 'express'; import session from 'express-session'; -import connectPgSimple from 'connect-pg-simple'; import authRouter from './routes/auth.js'; import { createApiRouter } from './routes/api.js'; import { requireAuthPage } from './middleware/auth.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const publicDir = join(__dirname, 'public'); -const PgSession = connectPgSimple(session); export function mountDashboard(app, client) { app.set('trust proxy', 1); - const conString = process.env.POSTGRES_URL || process.env.DATABASE_URL; - - const store = conString - ? new PgSession({ conString, createTableIfMissing: true }) - : undefined; - app.use(session({ - store, secret: process.env.SESSION_SECRET || 'titanbot-dashboard-secret-change-me', resave: false, saveUninitialized: false, From 42e5bd50b45bc22c63218ab57ac05359c8804176 Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:27:28 -0600 Subject: [PATCH 096/115] Add detailed Discord token error logging --- src/web/routes/auth.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/web/routes/auth.js b/src/web/routes/auth.js index a1522d4aa..f867c53fe 100644 --- a/src/web/routes/auth.js +++ b/src/web/routes/auth.js @@ -52,7 +52,11 @@ router.get('/callback', async (req, res) => { }), }); - if (!tokenRes.ok) throw new Error('Token exchange failed'); + if (!tokenRes.ok) { + const errBody = await tokenRes.text(); + console.error('Discord token exchange failed:', tokenRes.status, errBody); + throw new Error('Token exchange failed'); + } const tokens = await tokenRes.json(); const [userRes, guildsRes] = await Promise.all([ From 0e26a1cec4b5656c76e7c11b465effc8bbaf4c76 Mon Sep 17 00:00:00 2001 From: Kjmodz <138188142+Kjmodz@users.noreply.github.com> Date: Fri, 26 Jun 2026 15:59:25 -0600 Subject: [PATCH 097/115] Rebrand dashboard sidebar to KJ'S CUSTOMS --- src/web/public/dashboard.html | 2 +- src/web/public/guild.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/web/public/dashboard.html b/src/web/public/dashboard.html index edc6fb9a2..fab8f79a2 100644 --- a/src/web/public/dashboard.html +++ b/src/web/public/dashboard.html @@ -12,7 +12,7 @@ diff --git a/src/web/public/guild.html b/src/web/public/guild.html index 3a709aacb..25d34d5ec 100644 --- a/src/web/public/guild.html +++ b/src/web/public/guild.html @@ -11,7 +11,7 @@