From 5872d0c0acb44b51d2632cd90bb82a0614d749d8 Mon Sep 17 00:00:00 2001 From: codebymitch Date: Fri, 17 Apr 2026 19:28:50 +1000 Subject: [PATCH 1/7] Error fix in welcome/goodbye --- src/commands/Welcome/goodbye.js | 2 +- src/commands/Welcome/welcome.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/Welcome/goodbye.js b/src/commands/Welcome/goodbye.js index dcfd25e3..736924a5 100644 --- a/src/commands/Welcome/goodbye.js +++ b/src/commands/Welcome/goodbye.js @@ -62,7 +62,7 @@ export default { const ping = options.getBoolean('ping') ?? false; const existingConfig = await getWelcomeConfig(client, guild.id); - if (hasGoodbyeSetup(existingConfig)) { + if (existingConfig?.goodbyeChannelId) { logger.info(`[Goodbye] Setup blocked because config already exists in channel ${existingConfig.goodbyeChannelId} for guild ${guild.id}`); return await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed( diff --git a/src/commands/Welcome/welcome.js b/src/commands/Welcome/welcome.js index 02d864b6..4461b0ba 100644 --- a/src/commands/Welcome/welcome.js +++ b/src/commands/Welcome/welcome.js @@ -67,7 +67,7 @@ export default { const ping = options.getBoolean('ping') ?? false; const existingConfig = await getWelcomeConfig(client, guild.id); - if (hasWelcomeSetup(existingConfig)) { + if (existingConfig?.channelId) { logger.info(`[Welcome] Setup blocked because config already exists in channel ${existingConfig.channelId} for guild ${guild.id}`); return await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed( From 02f38a3cb0ecc6d2f7991aa1c07b8422a9836a3b Mon Sep 17 00:00:00 2001 From: codebymitch Date: Sun, 19 Apr 2026 17:13:27 +1000 Subject: [PATCH 2/7] Made help embed title dynamic --- src/commands/Core/help.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/commands/Core/help.js b/src/commands/Core/help.js index 5e0a5803..839697d5 100644 --- a/src/commands/Core/help.js +++ b/src/commands/Core/help.js @@ -44,7 +44,7 @@ const CATEGORY_ICONS = { -async function createInitialHelpMenu() { +async function createInitialHelpMenu(client) { const commandsPath = path.join(__dirname, "../../commands"); const categoryDirs = ( await fs.readdir(commandsPath, { withFileTypes: true }) @@ -72,8 +72,9 @@ async function createInitialHelpMenu() { }), ]; + const botName = client.user.username; const embed = createEmbed({ - title: "๐Ÿค– TitanBot Help Center", + title: `๐Ÿค– ${botName} Help Center`, description: "Your all-in-one Discord companion for moderation, economy, fun, and server management.", color: 'primary' }); @@ -204,7 +205,7 @@ export default { const { MessageFlags } = await import('discord.js'); await InteractionHelper.safeDefer(interaction); - const { embeds, components } = await createInitialHelpMenu(); + const { embeds, components } = await createInitialHelpMenu(client); await InteractionHelper.safeEditReply(interaction, { embeds, From bafefc44cd38081bca58954513e91869b637d8fc Mon Sep 17 00:00:00 2001 From: codebymitch Date: Sun, 19 Apr 2026 17:21:44 +1000 Subject: [PATCH 3/7] Revert "Made help embed title dynamic" This reverts commit 02f38a3cb0ecc6d2f7991aa1c07b8422a9836a3b. --- src/commands/Core/help.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/commands/Core/help.js b/src/commands/Core/help.js index 839697d5..5e0a5803 100644 --- a/src/commands/Core/help.js +++ b/src/commands/Core/help.js @@ -44,7 +44,7 @@ const CATEGORY_ICONS = { -async function createInitialHelpMenu(client) { +async function createInitialHelpMenu() { const commandsPath = path.join(__dirname, "../../commands"); const categoryDirs = ( await fs.readdir(commandsPath, { withFileTypes: true }) @@ -72,9 +72,8 @@ async function createInitialHelpMenu(client) { }), ]; - const botName = client.user.username; const embed = createEmbed({ - title: `๐Ÿค– ${botName} Help Center`, + title: "๐Ÿค– TitanBot Help Center", description: "Your all-in-one Discord companion for moderation, economy, fun, and server management.", color: 'primary' }); @@ -205,7 +204,7 @@ export default { const { MessageFlags } = await import('discord.js'); await InteractionHelper.safeDefer(interaction); - const { embeds, components } = await createInitialHelpMenu(client); + const { embeds, components } = await createInitialHelpMenu(); await InteractionHelper.safeEditReply(interaction, { embeds, From ea76db34f0ba43b448b7b779030e6cd4d6d24dbb Mon Sep 17 00:00:00 2001 From: codebymitch Date: Sun, 19 Apr 2026 17:23:45 +1000 Subject: [PATCH 4/7] Pass client to help menu and dynamic title --- src/commands/Core/help.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/commands/Core/help.js b/src/commands/Core/help.js index 5e0a5803..34d2fbcc 100644 --- a/src/commands/Core/help.js +++ b/src/commands/Core/help.js @@ -1,4 +1,4 @@ -import { +๏ปฟimport { SlashCommandBuilder, ActionRowBuilder, ButtonBuilder, @@ -44,7 +44,7 @@ const CATEGORY_ICONS = { -async function createInitialHelpMenu() { +async function createInitialHelpMenu(client) { const commandsPath = path.join(__dirname, "../../commands"); const categoryDirs = ( await fs.readdir(commandsPath, { withFileTypes: true }) @@ -72,8 +72,9 @@ async function createInitialHelpMenu() { }), ]; + const botName = client?.user?.username || "Bot"; const embed = createEmbed({ - title: "๐Ÿค– TitanBot Help Center", + title: `๐Ÿค– ${botName} Help Center`, description: "Your all-in-one Discord companion for moderation, economy, fun, and server management.", color: 'primary' }); @@ -204,7 +205,7 @@ export default { const { MessageFlags } = await import('discord.js'); await InteractionHelper.safeDefer(interaction); - const { embeds, components } = await createInitialHelpMenu(); + const { embeds, components } = await createInitialHelpMenu(client); await InteractionHelper.safeEditReply(interaction, { embeds, From c8262adbed85cf7e6601056e72b0a8860acbced8 Mon Sep 17 00:00:00 2001 From: codebymitch Date: Sun, 19 Apr 2026 17:46:50 +1000 Subject: [PATCH 5/7] Use persistent ticket counter and cleanup logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace random ticket numbering with a persistent, incrementing ticket counter. Remove unused imports and remove/stop calling the old logEvent moderation logging across ticket commands and handlers. Standardize pin emoji from ๐Ÿ“ to ๐Ÿ“Œ in ticket pin/unpin logic. --- src/commands/Ticket/claim.js | 16 ++-------- src/commands/Ticket/close.js | 17 ++--------- src/commands/Ticket/priority.js | 17 ++--------- src/commands/Ticket/ticket.js | 12 +------- src/handlers/ticketButtons.js | 53 ++++----------------------------- src/services/ticket.js | 7 ++--- src/utils/database.js | 30 +++++++++++++++++++ 7 files changed, 45 insertions(+), 107 deletions(-) diff --git a/src/commands/Ticket/claim.js b/src/commands/Ticket/claim.js index f9ce05cb..e6594249 100644 --- a/src/commands/Ticket/claim.js +++ b/src/commands/Ticket/claim.js @@ -1,8 +1,6 @@ import { getColor } from '../../config/bot.js'; -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, MessageFlags } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { claimTicket } from '../../services/ticket.js'; -import { logEvent } from '../../utils/moderation.js'; +import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; +import { errorEmbed, successEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; import { handleInteractionError } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; @@ -82,16 +80,6 @@ export default { commandName: 'claim' }); - await logEvent({ - client, - guildId: interaction.guildId, - event: { - action: "Ticket Claimed", - target: channel.toString(), - executor: interaction.user.toString() - } - }); - } catch (error) { logger.error('Error executing claim command', { error: error.message, diff --git a/src/commands/Ticket/close.js b/src/commands/Ticket/close.js index 0bd6d539..35616717 100644 --- a/src/commands/Ticket/close.js +++ b/src/commands/Ticket/close.js @@ -1,8 +1,6 @@ import { getColor } from '../../config/bot.js'; -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, MessageFlags } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { closeTicket } from '../../services/ticket.js'; -import { logEvent } from '../../utils/moderation.js'; +import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, MessageFlags } from 'discord.js'; +import { errorEmbed, successEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; import { handleInteractionError } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; @@ -93,17 +91,6 @@ export default { commandName: 'close' }); - await logEvent({ - client, - guildId: interaction.guildId, - event: { - action: "Ticket Closed", - target: channel.toString(), - executor: interaction.user.toString(), - reason: reason - } - }); - } catch (error) { logger.error('Error executing close command', { error: error.message, diff --git a/src/commands/Ticket/priority.js b/src/commands/Ticket/priority.js index fbbc3ba6..8eac46b9 100644 --- a/src/commands/Ticket/priority.js +++ b/src/commands/Ticket/priority.js @@ -1,8 +1,6 @@ import { getColor } from '../../config/bot.js'; -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, MessageFlags } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { updateTicketPriority } from '../../services/ticket.js'; -import { logEvent } from '../../utils/moderation.js'; +import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; +import { errorEmbed, successEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; import { handleInteractionError } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; @@ -98,17 +96,6 @@ export default { commandName: 'priority' }); - await logEvent({ - client, - guildId: interaction.guildId, - event: { - action: "Priority Updated", - target: interaction.channel.toString(), - executor: interaction.user.toString(), - reason: `Priority set to ${priorityLevel.toUpperCase()}` - } - }); - } catch (error) { logger.error('Error executing priority command', { error: error.message, diff --git a/src/commands/Ticket/ticket.js b/src/commands/Ticket/ticket.js index 22ec056e..e3a1a501 100644 --- a/src/commands/Ticket/ticket.js +++ b/src/commands/Ticket/ticket.js @@ -2,7 +2,6 @@ import { getColor } from '../../config/bot.js'; import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags } from 'discord.js'; import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; import { getGuildConfig } from '../../services/guildConfig.js'; -import { logEvent } from '../../utils/moderation.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { logger } from '../../utils/logger.js'; import { handleInteractionError } from '../../utils/errorHandler.js'; @@ -286,16 +285,7 @@ description: panelMessage, }, ); - logEvent({ - client, - guildId: interaction.guildId, - event: { - action: "Ticket System Setup", - target: panelChannel.toString(), - executor: interaction.user.toString(), - reason: "Ticket panel configuration" - } - }); + } catch (error) { logger.error('Ticket setup error', { error: error.message, diff --git a/src/handlers/ticketButtons.js b/src/handlers/ticketButtons.js index c28c5b6f..0b4e8482 100644 --- a/src/handlers/ticketButtons.js +++ b/src/handlers/ticketButtons.js @@ -2,7 +2,6 @@ import { ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder, Attac import { createEmbed, errorEmbed, successEmbed } from '../utils/embeds.js'; import { createTicket, closeTicket, claimTicket, updateTicketPriority } from '../services/ticket.js'; import { getGuildConfig } from '../services/guildConfig.js'; -import { logEvent } from '../utils/moderation.js'; import { logTicketEvent } from '../utils/ticketLogging.js'; import { logger } from '../utils/logger.js'; import { InteractionHelper } from '../utils/interactionHelper.js'; @@ -352,16 +351,6 @@ const claimTicketHandler = { embeds: [successEmbed('Ticket Claimed', 'You have successfully claimed this ticket!')], flags: MessageFlags.Ephemeral }); - - await logEvent({ - client, - guildId: interaction.guildId, - event: { - action: 'Ticket Claimed', - target: interaction.channel.toString(), - executor: interaction.user.toString() - } - }); } else { await interaction.editReply({ embeds: [errorEmbed('Error', result.error || 'Failed to claim ticket.')], @@ -490,11 +479,11 @@ const pinTicketHandler = { } // Check if channel name already has ping emoji - const hasPingEmoji = channel.name.startsWith('๐Ÿ“'); + const hasPingEmoji = channel.name.startsWith('๐Ÿ“Œ'); if (hasPingEmoji) { // Unpin: remove emoji and update position - const newName = channel.name.replace(/^๐Ÿ“\s*/, ''); + const newName = channel.name.replace(/^๐Ÿ“Œ\s*/, ''); await channel.edit({ name: newName, position: 999 // Move to end @@ -502,7 +491,7 @@ const pinTicketHandler = { await interaction.editReply({ embeds: [createEmbed({ - title: '๐Ÿ“ Ticket Unpinned', + title: '๐Ÿ“Œ Ticket Unpinned', description: 'This ticket has been unpinned and moved back to normal position.', color: 0x95A5A6 })], @@ -517,7 +506,7 @@ const pinTicketHandler = { }); } else { // Pin: add emoji and update position - const newName = `๐Ÿ“ ${channel.name}`; + const newName = `๐Ÿ“Œ ${channel.name}`; await channel.edit({ name: newName, position: 0 // Move to top @@ -525,7 +514,7 @@ const pinTicketHandler = { await interaction.editReply({ embeds: [createEmbed({ - title: '๐Ÿ“ Ticket Pinned', + title: '๐Ÿ“Œ Ticket Pinned', description: 'This ticket has been pinned to the top of the category.', color: 0x3498db })], @@ -551,7 +540,7 @@ const pinTicketHandler = { executorId: interaction.user.id, metadata: { isPinned: !hasPingEmoji, - newChannelName: hasPingEmoji ? channel.name.replace(/^๐Ÿ“\s*/, '') : `๐Ÿ“ ${channel.name}` + newChannelName: hasPingEmoji ? channel.name.replace(/^๐Ÿ“Œ\s*/, '') : `๐Ÿ“Œ ${channel.name}` } } }); @@ -608,16 +597,6 @@ const unclaimTicketHandler = { embeds: [successEmbed('Ticket Unclaimed', 'You have successfully unclaimed this ticket!')], flags: MessageFlags.Ephemeral }); - - await logEvent({ - client, - guildId: interaction.guildId, - event: { - action: 'Ticket Unclaimed', - target: interaction.channel.toString(), - executor: interaction.user.toString() - } - }); } else { await interaction.editReply({ embeds: [errorEmbed('Error', result.error || 'Failed to unclaim ticket.')], @@ -681,16 +660,6 @@ const reopenTicketHandler = { embeds: [successEmbed('Ticket Reopened', reopenMessage)], flags: MessageFlags.Ephemeral }); - - await logEvent({ - client, - guildId: interaction.guildId, - event: { - action: 'Ticket Reopened', - target: interaction.channel.toString(), - executor: interaction.user.toString() - } - }); } else { await interaction.editReply({ embeds: [errorEmbed('Error', result.error || 'Failed to reopen ticket.')], @@ -750,16 +719,6 @@ const deleteTicketHandler = { embeds: [successEmbed('Ticket Deleted', 'This ticket will be permanently deleted in 3 seconds.')], flags: MessageFlags.Ephemeral }); - - await logEvent({ - client, - guildId: interaction.guildId, - event: { - action: 'Ticket Deleted', - target: interaction.channel.toString(), - executor: interaction.user.toString() - } - }); } else { await interaction.editReply({ embeds: [errorEmbed('Error', result.error || 'Failed to delete ticket.')], diff --git a/src/services/ticket.js b/src/services/ticket.js index b38387bd..bf876b86 100644 --- a/src/services/ticket.js +++ b/src/services/ticket.js @@ -10,7 +10,7 @@ import { AttachmentBuilder, } from 'discord.js'; import { getGuildConfig } from './guildConfig.js'; -import { getTicketData, saveTicketData, deleteTicketData, getOpenTicketCountForUser } from '../utils/database.js'; +import { getTicketData, saveTicketData, deleteTicketData, getOpenTicketCountForUser, incrementTicketCounter } from '../utils/database.js'; import { logger } from '../utils/logger.js'; import { createEmbed, errorEmbed } from '../utils/embeds.js'; import { logTicketEvent } from '../utils/ticketLogging.js'; @@ -46,8 +46,6 @@ function getPriorityMap() { const PRIORITY_MAP = getPriorityMap(); const TICKET_DELETE_DELAY_MS = 3000; const TICKET_DELETE_DELAY_SECONDS = Math.floor(TICKET_DELETE_DELAY_MS / 1000); -const TICKET_NUMBER_BASE = 100; -const TICKET_NUMBER_RANGE = 900; @@ -1134,8 +1132,7 @@ export async function unclaimTicket(channel, unclaimer) { } async function getNextTicketNumber(guildId) { - const randomTicket = Math.floor(Math.random() * TICKET_NUMBER_RANGE) + TICKET_NUMBER_BASE; - return randomTicket.toString(); + return await incrementTicketCounter(guildId); } export async function updateTicketPriority(channel, priority, updater) { diff --git a/src/utils/database.js b/src/utils/database.js index f59c240f..b09da839 100644 --- a/src/utils/database.js +++ b/src/utils/database.js @@ -676,6 +676,35 @@ export async function deleteTicketData(guildId, channelId) { await db.delete(key); } +export function getTicketCounterKey(guildId) { + return `guild:${guildId}:ticket:counter`; +} + +export async function getTicketCounter(guildId) { + if (!db.initialized) { + await db.initialize(); + } + + const key = getTicketCounterKey(guildId); + const counter = await db.get(key); + return counter || 0; +} + +export async function incrementTicketCounter(guildId) { + if (!db.initialized) { + await db.initialize(); + } + + const key = getTicketCounterKey(guildId); + const currentCounter = await getTicketCounter(guildId); + const nextCounter = currentCounter + 1; + + await db.set(key, nextCounter); + + // Return padded to 3 digits (001, 002, etc.) + return nextCounter.toString().padStart(3, '0'); +} + @@ -745,6 +774,7 @@ function normalizeWelcomeConfig(raw = {}) { leaveMessage, leaveEmbed, dmMessage: base.dmMessage ?? "", + goodbyePing: Boolean(base.goodbyePing), roleIds, autoRoleDelay: base.autoRoleDelay ?? 0, joinLogs: base.joinLogs ?? { enabled: false, channelId: null }, From 33449af723bdf0dc7f50a96f4c0f14026205f36d Mon Sep 17 00:00:00 2001 From: codebymitch Date: Mon, 20 Apr 2026 23:22:53 +1000 Subject: [PATCH 6/7] Persist and update giveaway winner pings --- src/commands/Giveaway/gend.js | 4 +++- src/commands/Giveaway/greroll.js | 34 ++++++++++++++++++++++++++------ src/services/giveawayService.js | 4 +++- 3 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/commands/Giveaway/gend.js b/src/commands/Giveaway/gend.js index f430da9b..0427b59a 100644 --- a/src/commands/Giveaway/gend.js +++ b/src/commands/Giveaway/gend.js @@ -139,9 +139,11 @@ export default { const winnerMentions = winners .map((id) => `<@${id}>`) .join(", "); - await channel.send({ + const winnerPingMsg = await channel.send({ content: `๐ŸŽ‰ CONGRATULATIONS ${winnerMentions}! You won the **${updatedGiveaway.prize}** giveaway! Please contact the host <@${updatedGiveaway.hostId}> to claim your prize.`, }); + updatedGiveaway.winnerPingMessageId = winnerPingMsg.id; + await saveGiveaway(interaction.client, interaction.guildId, updatedGiveaway); logger.info(`Giveaway ended with ${winners.length} winner(s): ${messageId}`); diff --git a/src/commands/Giveaway/greroll.js b/src/commands/Giveaway/greroll.js index da63c4a3..4207c125 100644 --- a/src/commands/Giveaway/greroll.js +++ b/src/commands/Giveaway/greroll.js @@ -160,9 +160,20 @@ export default { .map((id) => `<@${id}>`) .join(", "); - await channel.send({ - content: `๐Ÿ”„ **GIVEAWAY REROLL** ๐Ÿ”„ New winners for **${giveaway.prize}**: ${winnerMentions}!`, - }); + // Edit the original winner ping if it still exists, otherwise send a new one + const existingPingMsg = giveaway.winnerPingMessageId + ? await channel.messages.fetch(giveaway.winnerPingMessageId).catch(() => null) + : null; + if (existingPingMsg) { + await existingPingMsg.edit({ + content: `๐Ÿ”„ **GIVEAWAY REROLL** ๐Ÿ”„ New winners for **${giveaway.prize}**: ${winnerMentions}!`, + }); + } else { + const newPingMsg = await channel.send({ + content: `๐Ÿ”„ **GIVEAWAY REROLL** ๐Ÿ”„ New winners for **${giveaway.prize}**: ${winnerMentions}!`, + }); + updatedGiveaway.winnerPingMessageId = newPingMsg.id; + } logger.info(`Giveaway rerolled (message not found, but announced): ${messageId}`); @@ -229,9 +240,20 @@ export default { .map((id) => `<@${id}>`) .join(", "); - await channel.send({ - content: `๐Ÿ”„ **REROLL WINNERS** ๐Ÿ”„ CONGRATULATIONS ${winnerMentions}! You are the new winner(s) for the **${giveaway.prize}** giveaway! Please contact the host <@${giveaway.hostId}> to claim your prize.`, - }); + // Edit the original winner ping if it still exists, otherwise send a new one + const existingPingMsg = giveaway.winnerPingMessageId + ? await channel.messages.fetch(giveaway.winnerPingMessageId).catch(() => null) + : null; + if (existingPingMsg) { + await existingPingMsg.edit({ + content: `๐Ÿ”„ **REROLL WINNERS** ๐Ÿ”„ CONGRATULATIONS ${winnerMentions}! You are the new winner(s) for the **${giveaway.prize}** giveaway! Please contact the host <@${giveaway.hostId}> to claim your prize.`, + }); + } else { + const newPingMsg = await channel.send({ + content: `๐Ÿ”„ **REROLL WINNERS** ๐Ÿ”„ CONGRATULATIONS ${winnerMentions}! You are the new winner(s) for the **${giveaway.prize}** giveaway! Please contact the host <@${giveaway.hostId}> to claim your prize.`, + }); + updatedGiveaway.winnerPingMessageId = newPingMsg.id; + } logger.info(`Giveaway successfully rerolled: ${messageId} with ${newWinners.length} new winners`); diff --git a/src/services/giveawayService.js b/src/services/giveawayService.js index 8062cb67..10017bc4 100644 --- a/src/services/giveawayService.js +++ b/src/services/giveawayService.js @@ -475,7 +475,9 @@ export async function checkGiveaways(client) { if (winners.length > 0) { const winnerAnnouncement = `๐ŸŽ‰ Congratulations ${winnerMentions}! You won the **${giveaway.prize || 'giveaway'}**! Please contact <@${giveaway.hostId}> to claim your prize.`; - await channel.send({ content: winnerAnnouncement }); + const winnerPingMsg = await channel.send({ content: winnerAnnouncement }); + giveaway.winnerPingMessageId = winnerPingMsg.id; + await markGiveawayEnded(client, giveawayId, giveaway); try { From 5073e70db89ac2a3f4b7aa63149b7fa56b9d8884 Mon Sep 17 00:00:00 2001 From: codebymitch Date: Tue, 21 Apr 2026 15:37:01 +1000 Subject: [PATCH 7/7] Adopt new UI builders & adjust ticket feedback Replace legacy modal/action-row text inputs with the new LabelBuilder/TextDisplayBuilder/RadioGroup/Checkbox/FileUpload/RoleSelectMenu components across dashboards and tools (applications, embed builder, join-to-create, greet dashboard, reactroles). Improve modal safety with try/catch and timeout handling. Other tweaks: template selection moved to a string select, file uploads take priority over URL fields, and various UI/validation improvements. --- src/commands/Community/app-admin.js | 243 +++++++++--------- .../Community/modules/app_dashboard.js | 99 ++++--- src/commands/JoinToCreate/jointocreate.js | 49 +++- src/commands/Reaction_roles/reactroles.js | 37 +-- src/commands/Tools/embedbuilder.js | 150 ++++++----- .../Welcome/modules/greet_dashboard.js | 133 +++++++--- src/interactions/buttons/ticketFeedback.js | 158 ++++++++++++ src/services/ticket.js | 28 +- 8 files changed, 594 insertions(+), 303 deletions(-) create mode 100644 src/interactions/buttons/ticketFeedback.js diff --git a/src/commands/Community/app-admin.js b/src/commands/Community/app-admin.js index 06ce3d2d..099398c3 100644 --- a/src/commands/Community/app-admin.js +++ b/src/commands/Community/app-admin.js @@ -1,4 +1,4 @@ -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, ComponentType } from 'discord.js'; +import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, ComponentType, LabelBuilder, RoleSelectMenuBuilder } from 'discord.js'; import { createEmbed, errorEmbed, successEmbed } from '../../utils/embeds.js'; import { getColor } from '../../config/bot.js'; import { logger } from '../../utils/logger.js'; @@ -148,141 +148,150 @@ async function handleSetup(interaction) { }); } - // Show modal for setting up a new application + // Build modal using LabelBuilder API with a native role select dropdown const modal = new ModalBuilder() .setCustomId('app_setup_modal') .setTitle('Set Up New Application'); - const rows = [ - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('app_name') - .setLabel('Application Name') - .setStyle(TextInputStyle.Short) - .setPlaceholder('e.g., Moderator, Helper, Developer') - .setMaxLength(50) - .setMinLength(1) - .setRequired(true), - ), - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('role_id') - .setLabel('Role ID') - .setStyle(TextInputStyle.Short) - .setPlaceholder('Right-click a role and copy its ID') - .setMaxLength(20) - .setMinLength(1) - .setRequired(true), - ), - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('app_question_1') - .setLabel('Question 1 (required)') - .setStyle(TextInputStyle.Short) - .setPlaceholder('Why do you want this role?') - .setMaxLength(100) - .setMinLength(1) - .setRequired(true), - ), - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('app_question_2') - .setLabel('Question 2 (optional)') - .setStyle(TextInputStyle.Short) - .setPlaceholder('What experience do you have?') - .setMaxLength(100) - .setRequired(false), - ), - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('app_question_3') - .setLabel('Question 3 (optional)') - .setStyle(TextInputStyle.Short) - .setMaxLength(100) - .setRequired(false), - ), - ]; - - modal.addComponents(...rows); - - await interaction.showModal(modal); + const roleSelect = new RoleSelectMenuBuilder() + .setCustomId('role_id') + .setPlaceholder('Select the role users will apply for') + .setRequired(true); + + const roleLabel = new LabelBuilder() + .setLabel('Application Role') + .setDescription('The role that users will be applying for') + .setRoleSelectMenuComponent(roleSelect); + + const appNameInput = new TextInputBuilder() + .setCustomId('app_name') + .setStyle(TextInputStyle.Short) + .setPlaceholder('e.g., Moderator, Helper, Developer') + .setMaxLength(50) + .setMinLength(1) + .setRequired(true); + + const appNameLabel = new LabelBuilder() + .setLabel('Application Name') + .setTextInputComponent(appNameInput); + + const q1Input = new TextInputBuilder() + .setCustomId('app_question_1') + .setStyle(TextInputStyle.Short) + .setPlaceholder('Why do you want this role?') + .setMaxLength(100) + .setMinLength(1) + .setRequired(true); + + const q1Label = new LabelBuilder() + .setLabel('Question 1 (required)') + .setTextInputComponent(q1Input); + + const q2Input = new TextInputBuilder() + .setCustomId('app_question_2') + .setStyle(TextInputStyle.Short) + .setPlaceholder('What experience do you have?') + .setMaxLength(100) + .setRequired(false); + + const q2Label = new LabelBuilder() + .setLabel('Question 2 (optional)') + .setTextInputComponent(q2Input); + + const q3Input = new TextInputBuilder() + .setCustomId('app_question_3') + .setStyle(TextInputStyle.Short) + .setMaxLength(100) + .setRequired(false); + + const q3Label = new LabelBuilder() + .setLabel('Question 3 (optional)') + .setTextInputComponent(q3Input); + + modal.addLabelComponents(roleLabel, appNameLabel, q1Label, q2Label, q3Label); - try { - const submitted = await interaction.awaitModalSubmit({ - time: 15 * 60 * 1000, // 15 minutes - filter: (i) => - i.customId === 'app_setup_modal' && - i.user.id === interaction.user.id, - }); + await interaction.showModal(modal); - const appName = submitted.fields.getTextInputValue('app_name').trim(); - const roleId = submitted.fields.getTextInputValue('role_id').trim(); - const questions = [ - submitted.fields.getTextInputValue('app_question_1').trim(), - submitted.fields.getTextInputValue('app_question_2').trim(), - submitted.fields.getTextInputValue('app_question_3').trim(), - ].filter(q => q.length > 0); + const submitted = await interaction.awaitModalSubmit({ + time: 15 * 60 * 1000, // 15 minutes + filter: (i) => + i.customId === 'app_setup_modal' && + i.user.id === interaction.user.id, + }).catch(() => null); - // Get the role to verify it exists - let role; - try { - role = await interaction.guild.roles.fetch(roleId); - } catch (error) { - await submitted.reply({ - embeds: [errorEmbed('Invalid Role', 'The role ID you provided does not exist.')], - flags: ['Ephemeral'], - }); - return; - } + if (!submitted) { + logger.info('App setup modal dismissed or timed out', { guildId: interaction.guild.id, userId: interaction.user.id }); + return; + } - // Check if this role is already an application - const existingRoles = await getApplicationRoles(interaction.client, interaction.guild.id); - if (existingRoles.some(r => r.roleId === roleId)) { - await submitted.reply({ - embeds: [errorEmbed('Already Configured', `The role ${role} is already configured as an application.`)], - flags: ['Ephemeral'], - }); - return; - } + const appName = submitted.fields.getTextInputValue('app_name').trim(); + const selectedRoles = submitted.fields.getSelectedRoles('role_id'); + const roleId = selectedRoles.first()?.id; - // Add the role to applications with enabled status - existingRoles.push({ - roleId: roleId, - name: appName, - enabled: true, // New applications start enabled + if (!roleId) { + await submitted.reply({ + embeds: [errorEmbed('No Role Selected', 'You must select a role for the application.')], + flags: ['Ephemeral'], }); + return; + } - await saveApplicationRoles(interaction.client, interaction.guild.id, existingRoles); - - // Enable the system - const settings = await getApplicationSettings(interaction.client, interaction.guild.id); - if (!settings.enabled) { - await ApplicationService.updateSettings(interaction.client, interaction.guild.id, { enabled: true }); - } + const questions = [ + submitted.fields.getTextInputValue('app_question_1').trim(), + submitted.fields.getTextInputValue('app_question_2').trim(), + submitted.fields.getTextInputValue('app_question_3').trim(), + ].filter(q => q.length > 0); - // Save the questions for this specific role - await saveApplicationRoleSettings(interaction.client, interaction.guild.id, roleId, { questions }); + // Get the role to verify it exists + const role = await interaction.guild.roles.fetch(roleId).catch(() => null); + if (!role) { + await submitted.reply({ + embeds: [errorEmbed('Invalid Role', 'The selected role could not be found.')], + flags: ['Ephemeral'], + }); + return; + } + // Check if this role is already an application + const existingRoles = await getApplicationRoles(interaction.client, interaction.guild.id); + if (existingRoles.some(r => r.roleId === roleId)) { await submitted.reply({ - embeds: [successEmbed( - 'โœ… Application Created', - `**${appName}** application has been created for ${role}.\n\nYou can customize the log channel, manager roles, questions, and retention period in the dashboard.`, - )], + embeds: [errorEmbed('Already Configured', `The role ${role} is already configured as an application.`)], flags: ['Ephemeral'], }); + return; + } - // Auto-open dashboard with this app selected - setTimeout(() => { - appDashboard.execute(submitted, null, interaction.client, appName); - }, 500); + // Add the role to applications with enabled status + existingRoles.push({ + roleId: roleId, + name: appName, + enabled: true, // New applications start enabled + }); - } catch (error) { - if (error.message.includes('timeout')) { - logger.info('App setup modal timed out', { guildId: interaction.guild.id, userId: interaction.user.id }); - return; - } - throw error; + await saveApplicationRoles(interaction.client, interaction.guild.id, existingRoles); + + // Enable the system + const settings = await getApplicationSettings(interaction.client, interaction.guild.id); + if (!settings.enabled) { + await ApplicationService.updateSettings(interaction.client, interaction.guild.id, { enabled: true }); } + + // Save the questions for this specific role + await saveApplicationRoleSettings(interaction.client, interaction.guild.id, roleId, { questions }); + + await submitted.reply({ + embeds: [successEmbed( + 'โœ… Application Created', + `**${appName}** application has been created for ${role}.\n\nYou can customize the log channel, manager roles, questions, and retention period in the dashboard.`, + )], + flags: ['Ephemeral'], + }); + + // Auto-open dashboard with this app selected + setTimeout(() => { + appDashboard.execute(submitted, null, interaction.client, appName); + }, 500); } diff --git a/src/commands/Community/modules/app_dashboard.js b/src/commands/Community/modules/app_dashboard.js index 692ce1d2..beb9b7d8 100644 --- a/src/commands/Community/modules/app_dashboard.js +++ b/src/commands/Community/modules/app_dashboard.js @@ -14,6 +14,9 @@ import { MessageFlags, ComponentType, EmbedBuilder, + LabelBuilder, + CheckboxBuilder, + TextDisplayBuilder, } from 'discord.js'; import { InteractionHelper } from '../../../utils/interactionHelper.js'; import { successEmbed, errorEmbed } from '../../../utils/embeds.js'; @@ -511,27 +514,28 @@ function setupCollectors(interaction, settings, roles, guildId, client, selected }); btnCollector.on('collect', async btnInteraction => { - // Defer the interaction to prevent timeout errors - const deferred = await safeDeferInteraction(btnInteraction); - if (!deferred) return; - // Show confirmation modal + const appRoleForDelete = roles.find(r => r.roleId === selectedRoleId); + const appNameForDelete = appRoleForDelete?.name ?? 'this application'; + const confirmModal = new ModalBuilder() .setCustomId('app_delete_confirm') .setTitle('Confirm Application Deletion'); - confirmModal.addComponents( - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('confirm_text') - .setLabel(`Type "DELETE" to confirm deletion`) - .setStyle(TextInputStyle.Short) - .setPlaceholder('Type DELETE here') - .setMaxLength(6) - .setMinLength(6) - .setRequired(true), - ), - ); + const deleteWarningText = new TextDisplayBuilder() + .setContent(`โš ๏ธ You are about to permanently delete **${appNameForDelete}**. All stored applications and settings for this role will be removed and cannot be recovered.`); + + const deleteCheckbox = new CheckboxBuilder() + .setCustomId('confirm_delete') + .setDefault(false); + + const deleteCheckboxLabel = new LabelBuilder() + .setLabel('I confirm โ€” this cannot be undone') + .setCheckboxComponent(deleteCheckbox); + + confirmModal + .addTextDisplayComponents(deleteWarningText) + .addLabelComponents(deleteCheckboxLabel); try { await btnInteraction.showModal(confirmModal); @@ -559,10 +563,10 @@ function setupCollectors(interaction, settings, roles, guildId, client, selected return; } - const confirmText = confirmSubmit.fields.getTextInputValue('confirm_text').trim(); - if (confirmText !== 'DELETE') { + const confirmed = confirmSubmit.fields.getCheckbox('confirm_delete'); + if (!confirmed) { await confirmSubmit.reply({ - embeds: [errorEmbed('Incorrect Confirmation', 'You must type exactly "DELETE" to confirm.')], + embeds: [errorEmbed('Not Confirmed', 'You must tick the confirmation checkbox to delete the application.')], flags: MessageFlags.Ephemeral, }); return; @@ -1150,30 +1154,43 @@ async function handleRoleRemove(selectInteraction, rootInteraction, settings, ro async function handleRetention(selectInteraction, rootInteraction, settings, roles, guildId, client) { const modal = new ModalBuilder() .setCustomId('app_cfg_retention') - .setTitle('Application Retention Periods') - .addComponents( - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('pending_days') - .setLabel('Pending retention (days, 1โ€“3650)') - .setStyle(TextInputStyle.Short) - .setValue(String(settings.pendingApplicationRetentionDays ?? 30)) - .setMaxLength(4) - .setMinLength(1) - .setRequired(true), - ), - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('reviewed_days') - .setLabel('Reviewed retention (days, 1โ€“3650)') - .setStyle(TextInputStyle.Short) - .setValue(String(settings.reviewedApplicationRetentionDays ?? 14)) - .setMaxLength(4) - .setMinLength(1) - .setRequired(true), - ), + .setTitle('Application Retention Periods'); + + const retentionInfo = new TextDisplayBuilder() + .setContent( + '**Pending** โ€” how long unanswered/in-progress applications are kept before being automatically removed.\n' + + '**Reviewed** โ€” how long approved or denied applications are kept.\n' + + '-# Enter a whole number between 1 and 3650 (max 10 years).', + ); + + const pendingLabel = new LabelBuilder() + .setLabel('Pending retention (days)') + .setTextInputComponent( + new TextInputBuilder() + .setCustomId('pending_days') + .setStyle(TextInputStyle.Short) + .setValue(String(settings.pendingApplicationRetentionDays ?? 30)) + .setMaxLength(4) + .setMinLength(1) + .setRequired(true), ); + const reviewedLabel = new LabelBuilder() + .setLabel('Reviewed retention (days)') + .setTextInputComponent( + new TextInputBuilder() + .setCustomId('reviewed_days') + .setStyle(TextInputStyle.Short) + .setValue(String(settings.reviewedApplicationRetentionDays ?? 14)) + .setMaxLength(4) + .setMinLength(1) + .setRequired(true), + ); + + modal + .addTextDisplayComponents(retentionInfo) + .addLabelComponents(pendingLabel, reviewedLabel); + await selectInteraction.showModal(modal); const submitted = await selectInteraction diff --git a/src/commands/JoinToCreate/jointocreate.js b/src/commands/JoinToCreate/jointocreate.js index bec14aea..b9fba286 100644 --- a/src/commands/JoinToCreate/jointocreate.js +++ b/src/commands/JoinToCreate/jointocreate.js @@ -1,5 +1,5 @@ import { getColor } from '../../config/bot.js'; -import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle, ComponentType, StringSelectMenuBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, EmbedBuilder } from 'discord.js'; +import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle, ComponentType, StringSelectMenuBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, EmbedBuilder, LabelBuilder } from 'discord.js'; import { errorEmbed, successEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; @@ -380,21 +380,42 @@ async function handleConfigSubcommand(interaction, client) { async function handleNameTemplateModal(interaction, triggerChannel, currentConfig, client) { try { + const TEMPLATE_OPTIONS = [ + { label: "{username}'s Room (Default)", value: "{username}'s Room" }, + { label: "{username}'s Channel", value: "{username}'s Channel" }, + { label: "{username}'s Lounge", value: "{username}'s Lounge" }, + { label: "{username}'s Space", value: "{username}'s Space" }, + { label: "{displayName}'s Room", value: "{displayName}'s Room" }, + { label: "{username}'s VC", value: "{username}'s VC" }, + { label: "๐ŸŽต {username}'s Music Room", value: "๐ŸŽต {username}'s Music Room" }, + { label: "๐ŸŽฎ {username}'s Gaming Room", value: "๐ŸŽฎ {username}'s Gaming Room" }, + { label: "๐Ÿ’ฌ {username}'s Chat Room", value: "๐Ÿ’ฌ {username}'s Chat Room" }, + { label: "{username}'s Private Room", value: "{username}'s Private Room" }, + ]; + + const currentTemplate = currentConfig.channelConfig?.nameTemplate + || currentConfig.channelNameTemplate + || "{username}'s Room"; + + const templateSelect = new StringSelectMenuBuilder() + .setCustomId('template') + .setPlaceholder('Pick a name template...') + .setOptions( + TEMPLATE_OPTIONS.map(o => ({ + label: o.label, + value: o.value, + default: o.value === currentTemplate, + })), + ); + + const templateLabel = new LabelBuilder() + .setLabel('Channel name template') + .setStringSelectMenuComponent(templateSelect); + const modal = new ModalBuilder() .setCustomId(`jtc_name_modal_${triggerChannel.id}`) .setTitle('Channel Name Template') - .addComponents( - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('template') - .setLabel('Enter new channel name template') - .setPlaceholder('e.g., {username}\'s Room') - .setStyle(TextInputStyle.Short) - .setRequired(true) - .setMaxLength(100) - .setValue(currentConfig.channelConfig.nameTemplate || currentConfig.channelNameTemplate || "{username}'s Room") - ) - ); + .addLabelComponents(templateLabel); await interaction.showModal(modal); @@ -412,7 +433,7 @@ async function handleNameTemplateModal(interaction, triggerChannel, currentConfi return; } - const newTemplate = modalSubmission.fields.getTextInputValue('template').trim(); + const [newTemplate] = modalSubmission.fields.getStringSelectValues('template'); await updateChannelConfig(client, interaction.guild.id, triggerChannel.id, { nameTemplate: newTemplate diff --git a/src/commands/Reaction_roles/reactroles.js b/src/commands/Reaction_roles/reactroles.js index cf84511a..bc762be8 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 } from 'discord.js'; +import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, ActionRowBuilder, StringSelectMenuBuilder, StringSelectMenuOptionBuilder, RoleSelectMenuBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, ButtonBuilder, ButtonStyle, MessageFlags, ComponentType, EmbedBuilder, LabelBuilder, CheckboxBuilder, TextDisplayBuilder } from 'discord.js'; import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; import { handleInteractionError, createError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; @@ -991,19 +991,22 @@ async function handleDeletePanel(btnInteraction, rootInteraction, panelData, pan const deleteModal = new ModalBuilder() .setCustomId('rr_delete_confirm_modal') - .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) - ) - ); + .setTitle('Delete Reaction Role Panel'); + + const deleteWarningText = new TextDisplayBuilder() + .setContent(`โš ๏ธ You are about to permanently delete the panel **${title}**. This will remove the Discord message and all associated reaction role assignments.`); + + const deleteCheckbox = new CheckboxBuilder() + .setCustomId('delete_confirmation') + .setDefault(false); + + const deleteCheckboxLabel = new LabelBuilder() + .setLabel('I confirm โ€” this cannot be undone') + .setCheckboxComponent(deleteCheckbox); + + deleteModal + .addTextDisplayComponents(deleteWarningText) + .addLabelComponents(deleteCheckboxLabel); await btnInteraction.showModal(deleteModal); @@ -1019,11 +1022,11 @@ async function handleDeletePanel(btnInteraction, rootInteraction, panelData, pan return; } - const confirmation = submitted.fields.getTextInputValue('delete_confirmation').trim(); + const confirmed = submitted.fields.getCheckbox('delete_confirmation'); - if (confirmation !== 'DELETE') { + if (!confirmed) { await submitted.reply({ - embeds: [errorEmbed('Incorrect Confirmation', 'You must type "DELETE" exactly to confirm deletion.')], + embeds: [errorEmbed('Not Confirmed', 'You must tick the confirmation checkbox to delete the panel.')], flags: MessageFlags.Ephemeral, }); await showPanelDashboard(rootInteraction, panelData, discordMsg, guildId, guild); diff --git a/src/commands/Tools/embedbuilder.js b/src/commands/Tools/embedbuilder.js index 95e05657..c398ce2a 100644 --- a/src/commands/Tools/embedbuilder.js +++ b/src/commands/Tools/embedbuilder.js @@ -14,6 +14,8 @@ import { ComponentType, ChannelType, EmbedBuilder, + LabelBuilder, + RadioGroupBuilder, } from 'discord.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { successEmbed, errorEmbed } from '../../utils/embeds.js'; @@ -627,38 +629,44 @@ async function handleAddField(selectInteraction, rootInteraction, state) { const modal = new ModalBuilder() .setCustomId('eb_add_field') - .setTitle('Add Field') - .addComponents( - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('field_name') - .setLabel('Field Name (max 256 characters)') - .setStyle(TextInputStyle.Short) - .setMaxLength(256) - .setRequired(true) - .setPlaceholder('Field Title'), - ), - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('field_value') - .setLabel('Field Value (max 1024 characters)') - .setStyle(TextInputStyle.Paragraph) - .setMaxLength(1024) - .setRequired(true) - .setPlaceholder('Field content goes here...'), - ), - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('field_inline') - .setLabel('Inline? Type "yes" to place side-by-side') - .setStyle(TextInputStyle.Short) - .setValue('no') - .setMaxLength(3) - .setRequired(false) - .setPlaceholder('yes or no'), - ), + .setTitle('Add Field'); + + const fieldNameLabel = new LabelBuilder() + .setLabel('Field Name (max 256 characters)') + .setTextInputComponent( + new TextInputBuilder() + .setCustomId('field_name') + .setStyle(TextInputStyle.Short) + .setMaxLength(256) + .setRequired(true) + .setPlaceholder('Field Title'), + ); + + const fieldValueLabel = new LabelBuilder() + .setLabel('Field Value (max 1024 characters)') + .setTextInputComponent( + new TextInputBuilder() + .setCustomId('field_value') + .setStyle(TextInputStyle.Paragraph) + .setMaxLength(1024) + .setRequired(true) + .setPlaceholder('Field content goes here...'), ); + const inlineRadio = new RadioGroupBuilder() + .setCustomId('field_inline') + .setRequired(false) + .addOptions([ + { label: 'No โ€” full width', value: 'no' }, + { label: 'Yes โ€” side-by-side', value: 'yes' }, + ]); + + const inlineLabel = new LabelBuilder() + .setLabel('Display inline?') + .setRadioGroupComponent(inlineRadio); + + modal.addLabelComponents(fieldNameLabel, fieldValueLabel, inlineLabel); + await selectInteraction.showModal(modal); const submitted = await selectInteraction @@ -672,8 +680,7 @@ async function handleAddField(selectInteraction, rootInteraction, state) { const name = submitted.fields.getTextInputValue('field_name').trim(); const value = submitted.fields.getTextInputValue('field_value').trim(); - const inlineRaw = submitted.fields.getTextInputValue('field_inline').trim().toLowerCase(); - const inline = inlineRaw === 'yes' || inlineRaw === 'y' || inlineRaw === 'true'; + const inline = submitted.fields.getRadioGroup('field_inline') === 'yes'; state.fields.push({ name, value, inline }); @@ -725,37 +732,51 @@ async function handleEditField(selectInteraction, rootInteraction, state) { const modal = new ModalBuilder() .setCustomId('eb_edit_field_modal') - .setTitle(`Edit Field ${idx + 1}`) - .addComponents( - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('field_name') - .setLabel('Field Name') - .setStyle(TextInputStyle.Short) - .setValue(field.name) - .setMaxLength(256) - .setRequired(true), - ), - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('field_value') - .setLabel('Field Value') - .setStyle(TextInputStyle.Paragraph) - .setValue(field.value.substring(0, 4000)) - .setMaxLength(1024) - .setRequired(true), - ), - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('field_inline') - .setLabel('Inline? (yes / no)') - .setStyle(TextInputStyle.Short) - .setValue(field.inline ? 'yes' : 'no') - .setMaxLength(3) - .setRequired(false), - ), + .setTitle(`Edit Field ${idx + 1}`); + + const editNameLabel = new LabelBuilder() + .setLabel('Field Name') + .setTextInputComponent( + new TextInputBuilder() + .setCustomId('field_name') + .setStyle(TextInputStyle.Short) + .setValue(field.name) + .setMaxLength(256) + .setRequired(true), + ); + + const editValueLabel = new LabelBuilder() + .setLabel('Field Value') + .setTextInputComponent( + new TextInputBuilder() + .setCustomId('field_value') + .setStyle(TextInputStyle.Paragraph) + .setValue(field.value.substring(0, 4000)) + .setMaxLength(1024) + .setRequired(true), ); + const editInlineRadio = new RadioGroupBuilder() + .setCustomId('field_inline') + .setRequired(false) + .addOptions([ + { label: 'No โ€” full width', value: 'no' }, + { label: 'Yes โ€” side-by-side', value: 'yes' }, + ]); + // Pre-select the current value + if (field.inline) { + editInlineRadio.setOptions([ + { label: 'No โ€” full width', value: 'no' }, + { label: 'Yes โ€” side-by-side', value: 'yes', default: true }, + ]); + } + + const editInlineLabel = new LabelBuilder() + .setLabel('Display inline?') + .setRadioGroupComponent(editInlineRadio); + + modal.addLabelComponents(editNameLabel, editValueLabel, editInlineLabel); + await pickInter.showModal(modal); const submitted = await pickInter @@ -768,10 +789,9 @@ async function handleEditField(selectInteraction, rootInteraction, state) { if (!submitted) return; - const name = submitted.fields.getTextInputValue('field_name').trim(); - const value = submitted.fields.getTextInputValue('field_value').trim(); - const inlineRaw = submitted.fields.getTextInputValue('field_inline').trim().toLowerCase(); - const inline = inlineRaw === 'yes' || inlineRaw === 'y' || inlineRaw === 'true'; + const name = submitted.fields.getTextInputValue('field_name').trim(); + const value = submitted.fields.getTextInputValue('field_value').trim(); + const inline = submitted.fields.getRadioGroup('field_inline') === 'yes'; state.fields[idx] = { name, value, inline }; diff --git a/src/commands/Welcome/modules/greet_dashboard.js b/src/commands/Welcome/modules/greet_dashboard.js index 9302c9f0..2296ccd2 100644 --- a/src/commands/Welcome/modules/greet_dashboard.js +++ b/src/commands/Welcome/modules/greet_dashboard.js @@ -13,6 +13,9 @@ import { MessageFlags, ComponentType, EmbedBuilder, + LabelBuilder, + FileUploadBuilder, + TextDisplayBuilder, } from 'discord.js'; import { InteractionHelper } from '../../../utils/interactionHelper.js'; import { successEmbed, errorEmbed } from '../../../utils/embeds.js'; @@ -339,7 +342,11 @@ export default { // โ”€โ”€โ”€ Welcome Channel โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ async function handleWelcomeChannel(selectInteraction, rootInteraction, cfg, guildId, client) { - await selectInteraction.deferUpdate(); + try { + await selectInteraction.deferUpdate(); + } catch { + return; + } const channelSelect = new ChannelSelectMenuBuilder() .setCustomId('greet_cfg_welcome_channel') @@ -427,7 +434,11 @@ async function handleWelcomeMessage(selectInteraction, rootInteraction, cfg, gui ), ); - await selectInteraction.showModal(modal); + try { + await selectInteraction.showModal(modal); + } catch { + return; + } const submitted = await selectInteraction .awaitModalSubmit({ @@ -455,20 +466,39 @@ 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') - .addComponents( - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('image_input') - .setLabel('Image URL (leave blank to remove)') - .setPlaceholder('https://example.com/welcome.png') - .setStyle(TextInputStyle.Short) - .setValue(cfg.welcomeImage || '') - .setRequired(false), - ), + .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), ); - await selectInteraction.showModal(modal); + modal + .addTextDisplayComponents(imageHint) + .addLabelComponents(urlLabel, uploadLabel); + + try { + await selectInteraction.showModal(modal); + } catch { + return; + } const submitted = await selectInteraction .awaitModalSubmit({ @@ -480,9 +510,11 @@ async function handleWelcomeImage(selectInteraction, rootInteraction, cfg, guild if (!submitted) return; - const imageUrl = submitted.fields.getTextInputValue('image_input').trim(); + // File upload takes priority over URL + const uploadedFiles = submitted.fields.getUploadedFiles('image_upload'); + let imageUrl = uploadedFiles?.at(0)?.url ?? submitted.fields.getTextInputValue('image_input').trim(); - // Validate image URL + // Validate URL if provided if (imageUrl) { try { new URL(imageUrl); @@ -537,7 +569,11 @@ async function handleWelcomePing(selectInteraction, rootInteraction, cfg, guildI // โ”€โ”€โ”€ Goodbye Channel โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ async function handleGoodbyeChannel(selectInteraction, rootInteraction, cfg, guildId, client) { - await selectInteraction.deferUpdate(); + try { + await selectInteraction.deferUpdate(); + } catch { + return; + } const channelSelect = new ChannelSelectMenuBuilder() .setCustomId('greet_cfg_goodbye_channel') @@ -625,7 +661,11 @@ async function handleGoodbyeMessage(selectInteraction, rootInteraction, cfg, gui ), ); - await selectInteraction.showModal(modal); + try { + await selectInteraction.showModal(modal); + } catch { + return; + } const submitted = await selectInteraction .awaitModalSubmit({ @@ -653,24 +693,43 @@ 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') - .addComponents( - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('image_input') - .setLabel('Image URL (leave blank to remove)') - .setPlaceholder('https://example.com/goodbye.png') - .setStyle(TextInputStyle.Short) - .setValue( - typeof cfg.leaveEmbed?.image === 'string' - ? cfg.leaveEmbed.image - : cfg.leaveEmbed?.image?.url || '' - ) - .setRequired(false), - ), + .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), ); - await selectInteraction.showModal(modal); + modal + .addTextDisplayComponents(imageHint) + .addLabelComponents(urlLabel, uploadLabel); + + try { + await selectInteraction.showModal(modal); + } catch { + return; + } const submitted = await selectInteraction .awaitModalSubmit({ @@ -682,9 +741,11 @@ async function handleGoodbyeImage(selectInteraction, rootInteraction, cfg, guild if (!submitted) return; - const imageUrl = submitted.fields.getTextInputValue('image_input').trim(); + // File upload takes priority over URL + const uploadedFiles = submitted.fields.getUploadedFiles('image_upload'); + let imageUrl = uploadedFiles?.at(0)?.url ?? submitted.fields.getTextInputValue('image_input').trim(); - // Validate image URL + // Validate URL if provided if (imageUrl) { try { new URL(imageUrl); diff --git a/src/interactions/buttons/ticketFeedback.js b/src/interactions/buttons/ticketFeedback.js new file mode 100644 index 00000000..ba898cc5 --- /dev/null +++ b/src/interactions/buttons/ticketFeedback.js @@ -0,0 +1,158 @@ +import { EmbedBuilder } from 'discord.js'; +import { getTicketData, saveTicketData } from '../../utils/database.js'; +import { logger } from '../../utils/logger.js'; +import { getColor } from '../../config/bot.js'; +import { getGuildConfig } from '../../services/guildConfig.js'; + +const STAR_LABELS = { + '1': 'โญ 1 โ€” Poor', + '2': 'โญโญ 2 โ€” Below Average', + '3': 'โญโญโญ 3 โ€” Average', + '4': 'โญโญโญโญ 4 โ€” Good', + '5': 'โญโญโญโญโญ 5 โ€” Excellent', +}; + +const feedbackHandler = { + name: 'ticket_feedback', + + async execute(interaction, client, args) { + // args = [guildId, channelId, rating] + const [guildId, channelId, ratingStr] = args; + + if (!guildId || !channelId || !ratingStr) { + await interaction.update({ + embeds: [ + new EmbedBuilder() + .setTitle('โš ๏ธ Invalid Feedback Link') + .setDescription('This feedback link appears to be malformed.') + .setColor(getColor('error')), + ], + components: [], + }); + return; + } + + let ticketData; + try { + ticketData = await getTicketData(guildId, channelId); + } catch (err) { + logger.warn('ticketFeedback: failed to load ticket data', { guildId, channelId, error: err.message }); + } + + if (!ticketData) { + await interaction.update({ + embeds: [ + new EmbedBuilder() + .setTitle('โš ๏ธ Ticket Not Found') + .setDescription('Could not find the ticket associated with this survey.') + .setColor(getColor('error')), + ], + components: [], + }); + return; + } + + if (interaction.user.id !== ticketData.userId) { + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setTitle('โŒ Not Allowed') + .setDescription('Only the ticket creator can submit feedback for this ticket.') + .setColor(getColor('error')), + ], + ephemeral: true, + }); + return; + } + + if (ticketData.feedback?.rating) { + await interaction.update({ + embeds: [ + new EmbedBuilder() + .setTitle('โœ… Already Submitted') + .setDescription(`You already rated this ticket **${STAR_LABELS[String(ticketData.feedback.rating)]}**.\nThank you for your feedback!`) + .setColor(getColor('success')), + ], + components: [], + }); + return; + } + + const rating = parseInt(ratingStr, 10); + const ratingLabel = STAR_LABELS[String(rating)] ?? `${rating} stars`; + + try { + ticketData.feedback = { + rating, + submittedAt: new Date().toISOString(), + }; + await saveTicketData(guildId, channelId, ticketData); + } catch (err) { + logger.error('ticketFeedback: failed to save feedback', { guildId, channelId, rating, error: err.message }); + } + + // Send feedback to logs channel + try { + const guildConfig = await getGuildConfig(interaction.client, guildId); + if (guildConfig.ticketLogsChannelId) { + const logsChannel = await interaction.client.channels.fetch(guildConfig.ticketLogsChannelId).catch(() => null); + if (logsChannel && logsChannel.isSendable()) { + const feedbackEmbed = new EmbedBuilder() + .setTitle('๐Ÿ“‹ Ticket Feedback Received') + .setDescription('User submitted feedback for a ticket') + .setColor(getColor('info')) + .addFields( + { name: 'Ticket ID', value: `\`${channelId}\``, inline: true }, + { name: 'Rating', value: ratingLabel, inline: true }, + { name: 'User', value: `<@${interaction.user.id}>`, inline: true }, + { name: 'Submitted', value: ``, inline: true }, + ) + .setThumbnail(interaction.user.displayAvatarURL()) + .setFooter({ text: `User ID: ${interaction.user.id}` }) + .setTimestamp(); + + await logsChannel.send({ embeds: [feedbackEmbed] }); + } + } + } catch (err) { + logger.warn('ticketFeedback: failed to send log', { guildId, channelId, error: err.message }); + } + + await interaction.update({ + embeds: [ + new EmbedBuilder() + .setTitle('โœ… Thanks for your feedback!') + .setDescription(`You rated your support experience **${ratingLabel}**.\n\nYour feedback has been recorded and helps us improve!`) + .setColor(getColor('success')) + .setFooter({ text: 'Thank you for using our support system.' }) + .setTimestamp(), + ], + components: [], + }); + + logger.info('Ticket feedback submitted', { + guildId, + channelId, + userId: interaction.user.id, + rating, + }); + }, +}; + +const declineHandler = { + name: 'ticket_feedback_decline', + + async execute(interaction) { + await interaction.update({ + embeds: [ + new EmbedBuilder() + .setTitle('๐Ÿ‘‹ No problem!') + .setDescription('You can always reach out again if you need further support.') + .setColor(getColor('default')), + ], + components: [], + }); + }, +}; + +export default [feedbackHandler, declineHandler]; diff --git a/src/services/ticket.js b/src/services/ticket.js index bf876b86..38816024 100644 --- a/src/services/ticket.js +++ b/src/services/ticket.js @@ -5,8 +5,6 @@ import { ButtonStyle, EmbedBuilder, PermissionFlagsBits, - StringSelectMenuBuilder, - StringSelectMenuOptionBuilder, AttachmentBuilder, } from 'discord.js'; import { getGuildConfig } from './guildConfig.js'; @@ -316,20 +314,24 @@ export async function closeTicket(channel, closer, reason = 'No reason provided' footer: { text: 'Your feedback helps us improve.' }, }); - const feedbackSelect = new StringSelectMenuBuilder() - .setCustomId(`ticket_feedback:${channel.guild.id}:${channel.id}`) - .setPlaceholder('Select a rating...') - .addOptions( - new StringSelectMenuOptionBuilder().setLabel('โญ 1 โ€” Poor').setValue('1').setDescription('The support was unhelpful or slow.'), - new StringSelectMenuOptionBuilder().setLabel('โญโญ 2 โ€” Below Average').setValue('2').setDescription('There was room for improvement.'), - new StringSelectMenuOptionBuilder().setLabel('โญโญโญ 3 โ€” Average').setValue('3').setDescription('Support was okay.'), - new StringSelectMenuOptionBuilder().setLabel('โญโญโญโญ 4 โ€” Good').setValue('4').setDescription('Support was helpful and friendly.'), - new StringSelectMenuOptionBuilder().setLabel('โญโญโญโญโญ 5 โ€” Excellent').setValue('5').setDescription('Outstanding support experience!'), - ); + const base = `ticket_feedback:${channel.guild.id}:${channel.id}`; + const starsRow = new ActionRowBuilder().addComponents( + new ButtonBuilder().setCustomId(`${base}:1`).setLabel('โญ 1').setStyle(ButtonStyle.Secondary), + new ButtonBuilder().setCustomId(`${base}:2`).setLabel('โญโญ 2').setStyle(ButtonStyle.Secondary), + new ButtonBuilder().setCustomId(`${base}:3`).setLabel('โญโญโญ 3').setStyle(ButtonStyle.Secondary), + new ButtonBuilder().setCustomId(`${base}:4`).setLabel('โญโญโญโญ 4').setStyle(ButtonStyle.Secondary), + new ButtonBuilder().setCustomId(`${base}:5`).setLabel('โญโญโญโญโญ 5').setStyle(ButtonStyle.Secondary), + ); + const declineRow = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`ticket_feedback_decline:${channel.guild.id}:${channel.id}`) + .setLabel('โŒ No thanks') + .setStyle(ButtonStyle.Secondary), + ); await ticketCreator.send({ embeds: [feedbackEmbed], - components: [new ActionRowBuilder().addComponents(feedbackSelect)], + components: [starsRow, declineRow], }); } catch (feedbackError) { logger.warn(`Could not send feedback survey to ticket creator ${ticketData.userId}: ${feedbackError.message}`);