From b12dbe93ad3154554e7fbae4b7964071c6a51bbf Mon Sep 17 00:00:00 2001 From: realkyx29-design Date: Mon, 10 Aug 2026 09:17:45 -0700 Subject: [PATCH 01/13] Update bot status from 'stalking' to 'Gstar Studio' --- 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 86dc861cc3..a791cae5f0 100644 --- a/src/config/bot.js +++ b/src/config/bot.js @@ -24,7 +24,7 @@ export const botConfig = { activities: [ { name: "Custom Status", // required by Discord API, not shown in the client - state: "stalking", // this is what people actually see + state: "Gstar Studio", // this is what people actually see type: 4, // Custom }, ], @@ -648,4 +648,4 @@ export function getRandomColor() { return colors[Math.floor(Math.random() * colors.length)]; } -export default botConfig; \ No newline at end of file +export default botConfig; From 91c15e4136c45cdf97f2c7268a775b018c746bfa Mon Sep 17 00:00:00 2001 From: realkyx29-design Date: Mon, 10 Aug 2026 09:30:02 -0700 Subject: [PATCH 02/13] Update fmt.Println message from 'Hello' to 'Goodbye' --- .../Ticket/modules/ticket_dashboard.js | 1079 ++++++----------- 1 file changed, 359 insertions(+), 720 deletions(-) diff --git a/src/commands/Ticket/modules/ticket_dashboard.js b/src/commands/Ticket/modules/ticket_dashboard.js index 17f3f43f88..1c08477286 100644 --- a/src/commands/Ticket/modules/ticket_dashboard.js +++ b/src/commands/Ticket/modules/ticket_dashboard.js @@ -8,7 +8,6 @@ import { TextInputStyle, RoleSelectMenuBuilder, ChannelSelectMenuBuilder, - UserSelectMenuBuilder, ButtonBuilder, ButtonStyle, ChannelType, @@ -22,7 +21,6 @@ import { logger } from '../../../utils/logger.js'; import { TitanBotError, ErrorTypes, replyUserError } from '../../../utils/errorHandler.js'; import { getGuildConfig, setGuildConfig } from '../../../services/config/guildConfig.js'; import { getGuildTicketStats } from '../../../utils/database/tickets.js'; -import { getUserTicketCount } from '../../../services/ticket.js'; import { getTicketPanelStatus, messageHasButtonCustomId, @@ -30,12 +28,15 @@ import { } from '../../../utils/panelStatus.js'; import { startDashboardSession } from '../../../utils/dashboardSession.js'; +// --------------------------------------------------------------------------- +// Panel / embed builders +// --------------------------------------------------------------------------- + function buildButtonRow(guildConfig, guildId, disabled = false, panelStatus = null) { const dmEnabled = guildConfig.dmOnClose !== false; const showRepost = panelStatus?.exists === false && panelStatus?.reason === 'panel_deleted'; const buttons = []; - if (showRepost) { buttons.push( new ButtonBuilder() @@ -74,27 +75,23 @@ function buildButtonRow(guildConfig, guildId, disabled = false, panelStatus = nu async function persistPanelMessageId(client, guildId, guildConfig, messageId) { if (!messageId || guildConfig.ticketPanelMessageId === messageId) return; guildConfig.ticketPanelMessageId = messageId; - if (client.db) { - await setGuildConfig(client, guildId, guildConfig); - } + if (client.db) await setGuildConfig(client, guildId, guildConfig); } -function buildPanelEmbed(config) { - return new EmbedBuilder() +const buildPanelEmbed = (config) => + new EmbedBuilder() .setTitle('Support Tickets') .setDescription(config.ticketPanelMessage || 'Click the button below to create a support ticket.') .setColor(getColor('info')); -} -function buildPanelButtonRow(config) { - return new ActionRowBuilder().addComponents( +const buildPanelButtonRow = (config) => + new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId('create_ticket') .setLabel(config.ticketButtonLabel || 'Create Ticket') .setStyle(ButtonStyle.Primary) .setEmoji('πŸ“©'), ); -} async function repostTicketPanel(client, guild, guildConfig, guildId) { const channel = await guild.channels.fetch(guildConfig.ticketPanelChannelId).catch(() => null); @@ -115,31 +112,22 @@ async function repostTicketPanel(client, guild, guildConfig, guildId) { return sentPanel; } -function formatCloseDuration(ms) { +const formatCloseDuration = (ms) => { if (ms == null) return '`N/A`'; const hours = Math.floor(ms / 3_600_000); const minutes = Math.floor((ms % 3_600_000) / 60_000); - if (hours > 0) return `${hours}h ${minutes}m`; - return `${minutes}m`; -} + return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`; +}; function buildDashboardEmbed(config, guild, panelStatus = null, ticketStats = null) { - const panelChannel = config.ticketPanelChannelId ? `<#${config.ticketPanelChannelId}>` : '`Not set`'; - const staffRole = config.ticketStaffRoleId ? `<@&${config.ticketStaffRoleId}>` : '`Not set`'; - const ticketLogsChannel = config.ticketLogsChannelId ? `<#${config.ticketLogsChannelId}>` : '`Not set`'; - const transcriptChannel = config.ticketTranscriptChannelId ? `<#${config.ticketTranscriptChannelId}>` : '`Not set`'; - - const openCategoryChannel = config.ticketCategoryId ? guild.channels.cache.get(config.ticketCategoryId) : null; - const openCategory = openCategoryChannel ? openCategoryChannel.toString() : '`Not set`'; - - const closedCategoryChannel = config.ticketClosedCategoryId ? guild.channels.cache.get(config.ticketClosedCategoryId) : null; - const closedCategory = closedCategoryChannel ? closedCategoryChannel.toString() : '`Not set`'; + const mention = (id, prefix = '#') => (id ? `<${prefix}${id}>` : '`Not set`'); + const category = (id) => { + const channel = id ? guild.channels.cache.get(id) : null; + return channel ? channel.toString() : '`Not set`'; + }; const rawMsg = config.ticketPanelMessage || 'Click the button below to create a support ticket.'; const panelMsg = `\`${rawMsg.length > 60 ? rawMsg.substring(0, 60) + '…' : rawMsg}\``; - const btnLabel = `\`${config.ticketButtonLabel || 'Create Ticket'}\``; - - let panelStatusValue = formatPanelStatusField(panelStatus); const openTickets = ticketStats ? String(ticketStats.openCount) : '`β€”`'; const avgCloseTime = ticketStats ? formatCloseDuration(ticketStats.avgCloseTimeMs) : '`β€”`'; @@ -152,19 +140,19 @@ function buildDashboardEmbed(config, guild, panelStatus = null, ticketStats = nu .setDescription(`Manage ticket system settings for **${guild.name}**.\nSelect an option below to modify a setting.`) .setColor(getColor('info')) .addFields( - { name: 'Panel Status', value: panelStatusValue, inline: false }, - { name: 'Panel Channel', value: panelChannel, inline: true }, - { name: 'Staff Role', value: staffRole, inline: true }, + { name: 'Panel Status', value: formatPanelStatusField(panelStatus), inline: false }, + { name: 'Panel Channel', value: mention(config.ticketPanelChannelId), inline: true }, + { name: 'Staff Role', value: mention(config.ticketStaffRoleId, '@&'), inline: true }, { name: '\u200B', value: '\u200B', inline: true }, - { name: 'Open Tickets Category', value: openCategory, inline: true }, - { name: 'Closed Tickets Category', value: closedCategory, inline: true }, + { name: 'Open Tickets Category', value: category(config.ticketCategoryId), inline: true }, + { name: 'Closed Tickets Category', value: category(config.ticketClosedCategoryId), inline: true }, { name: '\u200B', value: '\u200B', inline: true }, { name: 'Panel Message', value: panelMsg, inline: false }, - { name: 'Button Label', value: btnLabel, inline: true }, + { name: 'Button Label', value: `\`${config.ticketButtonLabel || 'Create Ticket'}\``, inline: true }, { name: 'Max Tickets/User', value: String(config.maxTicketsPerUser || 3), inline: true }, { name: 'DM on Close', value: config.dmOnClose !== false ? 'Enabled' : 'Disabled', inline: true }, - { name: 'Ticket Logs Channel', value: ticketLogsChannel, inline: true }, - { name: 'Transcript Channel', value: transcriptChannel, inline: true }, + { name: 'Ticket Logs Channel', value: mention(config.ticketLogsChannelId), inline: true }, + { name: 'Transcript Channel', value: mention(config.ticketTranscriptChannelId), inline: true }, { name: '\u200B', value: '\u200B', inline: true }, { name: 'Open Tickets', value: openTickets, inline: true }, { name: 'Avg Close Time', value: avgCloseTime, inline: true }, @@ -174,64 +162,33 @@ function buildDashboardEmbed(config, guild, panelStatus = null, ticketStats = nu .setTimestamp(); } -function buildSelectMenu(guildId) { - return new StringSelectMenuBuilder() +const SETTING_OPTIONS = [ + { label: 'Edit Panel Message', description: 'Change the message displayed on the ticket creation panel', value: 'panel_message', emoji: 'πŸ“' }, + { label: 'Edit Button Label', description: 'Change the label on the Create Ticket button', value: 'button_label', emoji: '🏷️' }, + { label: 'Change Open Tickets Category', description: 'Category where new tickets are created', value: 'open_category', emoji: 'πŸ“' }, + { label: 'Change Closed Tickets Category', description: 'Category where closed tickets are moved', value: 'closed_category', emoji: 'πŸ“‚' }, + { label: 'Set Max Tickets per User', description: 'Limit how many open tickets one user can have at once', value: 'max_tickets', emoji: 'πŸ”’' }, + { label: 'Set Ticket Logs Channel', description: 'Channel to receive ticket feedback, lifecycle events, and logs', value: 'logs_channel', emoji: '🎫' }, + { label: 'Set Transcript Channel', description: 'Channel to receive auto-generated transcripts on deletion', value: 'transcript_channel', emoji: 'πŸ“œ' }, +]; + +const buildSelectMenu = (guildId) => + new StringSelectMenuBuilder() .setCustomId(`ticket_config_${guildId}`) .setPlaceholder('Select a setting to configure...') - .addOptions( - new StringSelectMenuOptionBuilder() - .setLabel('Edit Panel Message') - .setDescription('Change the message displayed on the ticket creation panel') - .setValue('panel_message') - .setEmoji('πŸ“'), - new StringSelectMenuOptionBuilder() - .setLabel('Edit Button Label') - .setDescription('Change the label on the Create Ticket button') - .setValue('button_label') - .setEmoji('🏷️'), - new StringSelectMenuOptionBuilder() - .setLabel('Change Open Tickets Category') - .setDescription('Category where new tickets are created') - .setValue('open_category') - .setEmoji('πŸ“'), - new StringSelectMenuOptionBuilder() - .setLabel('Change Closed Tickets Category') - .setDescription('Category where closed tickets are moved') - .setValue('closed_category') - .setEmoji('πŸ“‚'), - new StringSelectMenuOptionBuilder() - .setLabel('Set Max Tickets per User') - .setDescription('Limit how many open tickets one user can have at once') - .setValue('max_tickets') - .setEmoji('πŸ”’'), - new StringSelectMenuOptionBuilder() - .setLabel('Set Ticket Logs Channel') - .setDescription('Channel to receive ticket feedback, lifecycle events, and logs') - .setValue('logs_channel') - .setEmoji('🎫'), - new StringSelectMenuOptionBuilder() - .setLabel('Set Transcript Channel') - .setDescription('Channel to receive auto-generated transcripts on deletion') - .setValue('transcript_channel') - .setEmoji('πŸ“œ'), - ); -} + .addOptions(SETTING_OPTIONS.map((o) => new StringSelectMenuOptionBuilder().setLabel(o.label).setDescription(o.description).setValue(o.value).setEmoji(o.emoji))); async function refreshDashboard(rootInteraction, guildConfig, guildId, client) { - const panelStatus = client - ? await getTicketPanelStatus(client, rootInteraction.guild, guildConfig) - : null; + const panelStatus = client ? await getTicketPanelStatus(client, rootInteraction.guild, guildConfig) : null; const ticketStats = client ? await getGuildTicketStats(guildId) : null; if (panelStatus?.recoveredId) { await persistPanelMessageId(client, guildId, guildConfig, panelStatus.recoveredId); } - const buttonRow = buildButtonRow(guildConfig, guildId, false, panelStatus); - const selectRow = new ActionRowBuilder().addComponents(buildSelectMenu(guildId)); await InteractionHelper.safeEditReply(rootInteraction, { embeds: [buildDashboardEmbed(guildConfig, rootInteraction.guild, panelStatus, ticketStats)], - components: [buttonRow, selectRow], + components: [buildButtonRow(guildConfig, guildId, false, panelStatus), new ActionRowBuilder().addComponents(buildSelectMenu(guildId))], }).catch(() => {}); } @@ -244,10 +201,7 @@ async function updateLivePanel(client, guild, config, guildId) { } if (!panelStatus.exists || !panelStatus.message) return false; - await panelStatus.message.edit({ - embeds: [buildPanelEmbed(config)], - components: [buildPanelButtonRow(config)], - }); + await panelStatus.message.edit({ embeds: [buildPanelEmbed(config)], components: [buildPanelButtonRow(config)] }); return true; } catch (error) { logger.warn('Failed to update live ticket panel:', error.message); @@ -255,623 +209,277 @@ async function updateLivePanel(client, guild, config, guildId) { } } -export default { - prefixOnly: false, - async execute(interaction, config, client) { - try { - const guildId = interaction.guild.id; - const guildConfig = await getGuildConfig(client, guildId); - - if (!guildConfig.ticketPanelChannelId) { - throw new TitanBotError( - 'Ticket system not configured', - ErrorTypes.CONFIGURATION, - 'The ticket system has not been set up yet. Run `/ticket setup` first to configure it.', - ); - } - - const panelStatus = await getTicketPanelStatus(client, interaction.guild, guildConfig); - if (panelStatus.recoveredId) { - await persistPanelMessageId(client, guildId, guildConfig, panelStatus.recoveredId); - } - - const ticketStats = await getGuildTicketStats(guildId); - - const selectRow = new ActionRowBuilder().addComponents(buildSelectMenu(guildId)); - const buttonRow = buildButtonRow(guildConfig, guildId, false, panelStatus); - - await startDashboardSession({ - interaction, - embeds: [buildDashboardEmbed(guildConfig, interaction.guild, panelStatus, ticketStats)], - components: [buttonRow, selectRow], - selectMenuId: `ticket_config_${guildId}`, - buttonMatcher: (customId) => - customId === `ticket_cfg_repost_${guildId}` || - customId === `ticket_cfg_dm_toggle_${guildId}` || - customId === `ticket_cfg_staff_role_btn_${guildId}` || - customId === `ticket_cfg_delete_${guildId}`, - onSelect: async (selectInteraction) => { - const selectedOption = selectInteraction.values[0]; - switch (selectedOption) { - case 'panel_message': - await handlePanelMessage(selectInteraction, interaction, guildConfig, guildId, client); - break; - case 'button_label': - await handleButtonLabel(selectInteraction, interaction, guildConfig, guildId, client); - break; - case 'staff_role': - await handleStaffRole(selectInteraction, interaction, guildConfig, guildId, client); - break; - case 'open_category': - await handleOpenCategory(selectInteraction, interaction, guildConfig, guildId, client); - break; - case 'closed_category': - await handleClosedCategory(selectInteraction, interaction, guildConfig, guildId, client); - break; - case 'max_tickets': - await handleMaxTickets(selectInteraction, interaction, guildConfig, guildId, client); - break; - case 'logs_channel': - await handleLogsChannel(selectInteraction, interaction, guildConfig, guildId, client); - break; - case 'transcript_channel': - await handleTranscriptChannel(selectInteraction, interaction, guildConfig, guildId, client); - break; - } - }, - onButton: async (btnInteraction) => { - if (btnInteraction.customId === `ticket_cfg_repost_${guildId}`) { - await handleRepostPanel(btnInteraction, interaction, guildConfig, guildId, client); - } else if (btnInteraction.customId === `ticket_cfg_dm_toggle_${guildId}`) { - await handleDmOnClose(btnInteraction, interaction, guildConfig, guildId, client); - } else if (btnInteraction.customId === `ticket_cfg_staff_role_btn_${guildId}`) { - await handleStaffRole(btnInteraction, interaction, guildConfig, guildId, client); - } else if (btnInteraction.customId === `ticket_cfg_delete_${guildId}`) { - await handleDeleteSystem(btnInteraction, interaction, guildConfig, guildId, client); - } - }, - }); - } catch (error) { - if (error instanceof TitanBotError) throw error; - logger.error('Unexpected error in ticket_config:', error); - throw new TitanBotError( - `Ticket config failed: ${error.message}`, - ErrorTypes.UNKNOWN, - 'Failed to open the ticket configuration dashboard.', - ); - } - }, -}; - -async function handlePanelMessage(selectInteraction, rootInteraction, guildConfig, guildId, client) { - const modal = new ModalBuilder() - .setCustomId('ticket_cfg_panel_msg') - .setTitle('πŸ“ Edit Panel Message') - .addComponents( - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('panel_msg_input') - .setLabel('Panel Message') - .setStyle(TextInputStyle.Paragraph) - .setValue( - guildConfig.ticketPanelMessage || - 'Click the button below to create a support ticket.', - ) - .setMaxLength(2000) - .setMinLength(1) - .setRequired(true) - .setPlaceholder('Click the button below to create a support ticket.'), - ), - ); - - await selectInteraction.showModal(modal); - - const submitted = await selectInteraction - .awaitModalSubmit({ - filter: i => - i.customId === 'ticket_cfg_panel_msg' && i.user.id === selectInteraction.user.id, - time: 120_000, - }) - .catch(() => null); - - if (!submitted) return; - - const newMessage = submitted.fields.getTextInputValue('panel_msg_input').trim(); - guildConfig.ticketPanelMessage = newMessage; - await setGuildConfig(client, guildId, guildConfig); - - const panelUpdated = await updateLivePanel(client, rootInteraction.guild, guildConfig, guildId); - - await submitted.reply({ - embeds: [ - successEmbed( - 'βœ… Panel Message Updated', - `The panel message has been updated.${ - panelUpdated - ? '\nThe live ticket panel has also been refreshed.' - : '\n> **Note:** The live panel could not be located. Use **Repost Panel** on the dashboard to restore it.' - }`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - - await refreshDashboard(rootInteraction, guildConfig, guildId, client); -} - -async function handleButtonLabel(selectInteraction, rootInteraction, guildConfig, guildId, client) { - const modal = new ModalBuilder() - .setCustomId('ticket_cfg_btn_label') - .setTitle('🏷️ Edit Button Label') - .addComponents( - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('btn_label_input') - .setLabel('Button Label (max 80 characters)') - .setStyle(TextInputStyle.Short) - .setValue(guildConfig.ticketButtonLabel || 'Create Ticket') - .setMaxLength(80) - .setMinLength(1) - .setRequired(true) - .setPlaceholder('Create Ticket'), - ), - ); - - await selectInteraction.showModal(modal); - - const submitted = await selectInteraction - .awaitModalSubmit({ - filter: i => - i.customId === 'ticket_cfg_btn_label' && i.user.id === selectInteraction.user.id, - time: 120_000, - }) - .catch(() => null); - - if (!submitted) return; - - const newLabel = submitted.fields.getTextInputValue('btn_label_input').trim(); - guildConfig.ticketButtonLabel = newLabel; - await setGuildConfig(client, guildId, guildConfig); - - const panelUpdated = await updateLivePanel(client, rootInteraction.guild, guildConfig, guildId); - - await submitted.reply({ - embeds: [ - successEmbed( - 'βœ… Button Label Updated', - `Button label changed to \`${newLabel}\`.${ - panelUpdated - ? '\nThe live ticket panel button has also been updated.' - : '\n> **Note:** The live panel could not be located. Use **Repost Panel** on the dashboard to restore it.' - }`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - - await refreshDashboard(rootInteraction, guildConfig, guildId, client); -} - -async function handleStaffRole(selectInteraction, rootInteraction, guildConfig, guildId, client) { +// --------------------------------------------------------------------------- +// Generic flows β€” every "pick a role/channel" and "type text in a modal" +// handler below is built from these two helpers instead of being hand-rolled. +// --------------------------------------------------------------------------- + +/** + * Shows a followUp with a select menu, waits for a pick, saves it, and + * refreshes the dashboard. Powers the staff-role / category / channel pickers. + */ +async function runSelectFlow({ + selectInteraction, rootInteraction, guildConfig, guildId, client, + customId, componentType, menu, embedTitle, embedDescription, + getSelected, onSave, timeoutMessage, +}) { await selectInteraction.deferUpdate(); - const roleSelect = new RoleSelectMenuBuilder() - .setCustomId('ticket_cfg_staff_role') - .setPlaceholder('Select the staff role...') - .setMaxValues(1); - - const row = new ActionRowBuilder().addComponents(roleSelect); - await selectInteraction.followUp({ - embeds: [ - new EmbedBuilder() - .setTitle('πŸ›‘οΈ Change Staff Role') - .setDescription( - `**Current:** ${guildConfig.ticketStaffRoleId ? `<@&${guildConfig.ticketStaffRoleId}>` : '`Not set`'}\n\nSelect the role that should have staff access to manage tickets.`, - ) - .setColor(getColor('info')), - ], - components: [row], + embeds: [new EmbedBuilder().setTitle(embedTitle).setDescription(embedDescription).setColor(getColor('info'))], + components: [new ActionRowBuilder().addComponents(menu)], flags: MessageFlags.Ephemeral, }); - const roleCollector = rootInteraction.channel.createMessageComponentCollector({ - componentType: ComponentType.RoleSelect, - filter: i => - i.user.id === selectInteraction.user.id && i.customId === 'ticket_cfg_staff_role', - time: 60_000, - max: 1, - }); - - roleCollector.on('collect', async roleInteraction => { - await roleInteraction.deferUpdate(); - const role = roleInteraction.roles.first(); - - guildConfig.ticketStaffRoleId = role.id; - await setGuildConfig(client, guildId, guildConfig); - - await roleInteraction.followUp({ - embeds: [successEmbed('Staff Role Updated', `Staff role set to ${role}.`)], - flags: MessageFlags.Ephemeral, - }); - - await refreshDashboard(rootInteraction, guildConfig, guildId, client); - }); - - roleCollector.on('end', (collected, reason) => { - if (reason === 'time' && collected.size === 0) { - replyUserError(selectInteraction, { - type: ErrorTypes.RATE_LIMIT, - message: 'No role was selected. The staff role was not changed.', - }).catch(() => {}); - } - }); -} - -async function handleOpenCategory(selectInteraction, rootInteraction, guildConfig, guildId, client) { - await selectInteraction.deferUpdate(); - - const channelSelect = new ChannelSelectMenuBuilder() - .setCustomId('ticket_cfg_open_cat') - .setPlaceholder('Select a category...') - .addChannelTypes(ChannelType.GuildCategory) - .setMaxValues(1); - - await selectInteraction.followUp({ - embeds: [ - new EmbedBuilder() - .setTitle('πŸ“ Change Open Tickets Category') - .setDescription( - `**Current:** ${guildConfig.ticketCategoryId ? `<#${guildConfig.ticketCategoryId}>` : '`Not set`'}\n\nSelect the category where new tickets will be created.`, - ) - .setColor(getColor('info')), - ], - components: [new ActionRowBuilder().addComponents(channelSelect)], - flags: MessageFlags.Ephemeral, - }); - - const catCollector = rootInteraction.channel.createMessageComponentCollector({ - componentType: ComponentType.ChannelSelect, - filter: i => - i.user.id === selectInteraction.user.id && i.customId === 'ticket_cfg_open_cat', + const collector = rootInteraction.channel.createMessageComponentCollector({ + componentType, + filter: (i) => i.user.id === selectInteraction.user.id && i.customId === customId, time: 60_000, max: 1, }); - catCollector.on('collect', async catInteraction => { - await catInteraction.deferUpdate(); - const category = catInteraction.channels.first(); - - guildConfig.ticketCategoryId = category.id; - await setGuildConfig(client, guildId, guildConfig); - - await catInteraction.followUp({ - embeds: [ - successEmbed( - 'Open Category Updated', - `New tickets will now be created in **${category.name}**.`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - + collector.on('collect', async (picked) => { + await picked.deferUpdate(); + const resultEmbed = await onSave(getSelected(picked)); + await picked.followUp({ embeds: [resultEmbed], flags: MessageFlags.Ephemeral }); await refreshDashboard(rootInteraction, guildConfig, guildId, client); }); - catCollector.on('end', (collected, reason) => { - if (reason === 'time' && collected.size === 0) { - replyUserError(selectInteraction, { - type: ErrorTypes.RATE_LIMIT, - message: 'No category was selected. The setting was not changed.', - }).catch(() => {}); - } - }); -} - -async function handleClosedCategory(selectInteraction, rootInteraction, guildConfig, guildId, client) { - await selectInteraction.deferUpdate(); - - const channelSelect = new ChannelSelectMenuBuilder() - .setCustomId('ticket_cfg_closed_cat') - .setPlaceholder('Select a category...') - .addChannelTypes(ChannelType.GuildCategory) - .setMaxValues(1); - - await selectInteraction.followUp({ - embeds: [ - new EmbedBuilder() - .setTitle('πŸ“‚ Change Closed Tickets Category') - .setDescription( - `**Current:** ${guildConfig.ticketClosedCategoryId ? `<#${guildConfig.ticketClosedCategoryId}>` : '`Not set`'}\n\nSelect the category where closed tickets will be moved.`, - ) - .setColor(getColor('info')), - ], - components: [new ActionRowBuilder().addComponents(channelSelect)], - flags: MessageFlags.Ephemeral, - }); - - const catCollector = rootInteraction.channel.createMessageComponentCollector({ - componentType: ComponentType.ChannelSelect, - filter: i => - i.user.id === selectInteraction.user.id && i.customId === 'ticket_cfg_closed_cat', - time: 60_000, - max: 1, - }); - - catCollector.on('collect', async catInteraction => { - await catInteraction.deferUpdate(); - const category = catInteraction.channels.first(); - - guildConfig.ticketClosedCategoryId = category.id; - await setGuildConfig(client, guildId, guildConfig); - - await catInteraction.followUp({ - embeds: [ - successEmbed( - 'Closed Category Updated', - `Closed tickets will now be moved to **${category.name}**.`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - - await refreshDashboard(rootInteraction, guildConfig, guildId, client); - }); - - catCollector.on('end', (collected, reason) => { + collector.on('end', (collected, reason) => { if (reason === 'time' && collected.size === 0) { - replyUserError(selectInteraction, { - type: ErrorTypes.RATE_LIMIT, - message: 'No category was selected. The setting was not changed.', - }).catch(() => {}); + replyUserError(selectInteraction, { type: ErrorTypes.RATE_LIMIT, message: timeoutMessage }).catch(() => {}); } }); } -async function handleMaxTickets(selectInteraction, rootInteraction, guildConfig, guildId, client) { - const modal = new ModalBuilder() - .setCustomId('ticket_cfg_max_tickets') - .setTitle('Set Max Tickets per User') - .addComponents( - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('max_tickets_input') - .setLabel('Max Open Tickets (1–10)') - .setStyle(TextInputStyle.Short) - .setValue(String(guildConfig.maxTicketsPerUser || 3)) - .setMaxLength(2) - .setMinLength(1) - .setRequired(true) - .setPlaceholder('3'), - ), - ); +/** + * Shows a single-field modal, waits for submission, validates + saves the + * value, and refreshes the dashboard. Powers panel message / button label / max tickets. + */ +async function runModalFlow({ + selectInteraction, rootInteraction, guildConfig, guildId, client, + modalId, modalTitle, inputId, inputLabel, inputStyle, currentValue, + maxLength, minLength = 1, placeholder, validate, onSave, +}) { + const modal = new ModalBuilder().setCustomId(modalId).setTitle(modalTitle).addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId(inputId) + .setLabel(inputLabel) + .setStyle(inputStyle) + .setValue(String(currentValue ?? '')) + .setMaxLength(maxLength) + .setMinLength(minLength) + .setRequired(true) + .setPlaceholder(placeholder), + ), + ); await selectInteraction.showModal(modal); const submitted = await selectInteraction - .awaitModalSubmit({ - filter: i => - i.customId === 'ticket_cfg_max_tickets' && i.user.id === selectInteraction.user.id, - time: 120_000, - }) + .awaitModalSubmit({ filter: (i) => i.customId === modalId && i.user.id === selectInteraction.user.id, time: 120_000 }) .catch(() => null); - if (!submitted) return; - const raw = submitted.fields.getTextInputValue('max_tickets_input').trim(); - const newMax = parseInt(raw, 10); + const value = submitted.fields.getTextInputValue(inputId).trim(); - if (Number.isNaN(newMax) || newMax < 1 || newMax > 10) { - await replyUserError(submitted, { - type: ErrorTypes.VALIDATION, - message: 'Max tickets must be a whole number between **1** and **10**.', - }); + const validationError = validate?.(value); + if (validationError) { + await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: validationError }); return; } - guildConfig.maxTicketsPerUser = newMax; - await setGuildConfig(client, guildId, guildConfig); - - await submitted.reply({ - embeds: [ - successEmbed( - 'Max Tickets Updated', - `Users can now have at most **${newMax}** open ticket${newMax !== 1 ? 's' : ''} at a time.`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - + const resultEmbed = await onSave(value, submitted); + await submitted.reply({ embeds: [resultEmbed], flags: MessageFlags.Ephemeral }); await refreshDashboard(rootInteraction, guildConfig, guildId, client); } -async function handleDmOnClose(btnInteraction, rootInteraction, guildConfig, guildId, client) { - await btnInteraction.deferUpdate(); +const channelSelectMenu = (customId, placeholder) => + new ChannelSelectMenuBuilder().setCustomId(customId).setPlaceholder(placeholder).addChannelTypes(ChannelType.GuildText).setMaxValues(1); - const newState = guildConfig.dmOnClose === false; - guildConfig.dmOnClose = newState; - await setGuildConfig(client, guildId, guildConfig); +const categorySelectMenu = (customId, placeholder) => + new ChannelSelectMenuBuilder().setCustomId(customId).setPlaceholder(placeholder).addChannelTypes(ChannelType.GuildCategory).setMaxValues(1); - await btnInteraction.followUp({ - embeds: [ - successEmbed( - 'DM on Close Updated', - `Users will **${newState ? 'now' : 'no longer'}** receive a DM when their ticket is closed.`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - - await refreshDashboard(rootInteraction, guildConfig, guildId, client); -} - -async function handleLogsChannel(selectInteraction, rootInteraction, guildConfig, guildId, client) { - await selectInteraction.deferUpdate(); +// --------------------------------------------------------------------------- +// Individual setting handlers +// --------------------------------------------------------------------------- - const channelSelect = new ChannelSelectMenuBuilder() - .setCustomId('ticket_cfg_logs_channel') - .setPlaceholder('Select a channel...') - .addChannelTypes(ChannelType.GuildText) - .setMaxValues(1); +const panelUpdateNote = (updated) => + updated + ? '\nThe live ticket panel has also been refreshed.' + : '\n> **Note:** The live panel could not be located. Use **Repost Panel** on the dashboard to restore it.'; - await selectInteraction.followUp({ - embeds: [ - new EmbedBuilder() - .setTitle('🎫 Select Ticket Logs Channel') - .setDescription('Choose where ticket feedback, lifecycle events (open, close, claim, etc.), and other logs will be sent.') - .setColor(getColor('info')), - ], - components: [new ActionRowBuilder().addComponents(channelSelect)], - flags: MessageFlags.Ephemeral, +async function handlePanelMessage(selectInteraction, rootInteraction, guildConfig, guildId, client) { + await runModalFlow({ + selectInteraction, rootInteraction, guildConfig, guildId, client, + modalId: 'ticket_cfg_panel_msg', + modalTitle: 'πŸ“ Edit Panel Message', + inputId: 'panel_msg_input', + inputLabel: 'Panel Message', + inputStyle: TextInputStyle.Paragraph, + currentValue: guildConfig.ticketPanelMessage || 'Click the button below to create a support ticket.', + maxLength: 2000, + placeholder: 'Click the button below to create a support ticket.', + onSave: async (value) => { + guildConfig.ticketPanelMessage = value; + await setGuildConfig(client, guildId, guildConfig); + const updated = await updateLivePanel(client, rootInteraction.guild, guildConfig, guildId); + return successEmbed('βœ… Panel Message Updated', `The panel message has been updated.${panelUpdateNote(updated)}`); + }, }); +} - const collector = rootInteraction.channel.createMessageComponentCollector({ - componentType: ComponentType.ChannelSelect, - filter: i => i.user.id === selectInteraction.user.id && i.customId === 'ticket_cfg_logs_channel', - time: 60_000, - max: 1, +async function handleButtonLabel(selectInteraction, rootInteraction, guildConfig, guildId, client) { + await runModalFlow({ + selectInteraction, rootInteraction, guildConfig, guildId, client, + modalId: 'ticket_cfg_btn_label', + modalTitle: '🏷️ Edit Button Label', + inputId: 'btn_label_input', + inputLabel: 'Button Label (max 80 characters)', + inputStyle: TextInputStyle.Short, + currentValue: guildConfig.ticketButtonLabel || 'Create Ticket', + maxLength: 80, + placeholder: 'Create Ticket', + onSave: async (value) => { + guildConfig.ticketButtonLabel = value; + await setGuildConfig(client, guildId, guildConfig); + const updated = await updateLivePanel(client, rootInteraction.guild, guildConfig, guildId); + return successEmbed('βœ… Button Label Updated', `Button label changed to \`${value}\`.${panelUpdateNote(updated)}`); + }, }); +} - collector.on('collect', async channelInteraction => { - await channelInteraction.deferUpdate(); - const channel = channelInteraction.channels.first(); - - guildConfig.ticketLogsChannelId = channel.id; - await setGuildConfig(client, guildId, guildConfig); - - await channelInteraction.followUp({ - embeds: [successEmbed('Logs Channel Updated', `Ticket logs will be sent to ${channel}`)], - flags: MessageFlags.Ephemeral, - }); - - await refreshDashboard(rootInteraction, guildConfig, guildId, client); +async function handleMaxTickets(selectInteraction, rootInteraction, guildConfig, guildId, client) { + await runModalFlow({ + selectInteraction, rootInteraction, guildConfig, guildId, client, + modalId: 'ticket_cfg_max_tickets', + modalTitle: 'Set Max Tickets per User', + inputId: 'max_tickets_input', + inputLabel: 'Max Open Tickets (1–10)', + inputStyle: TextInputStyle.Short, + currentValue: guildConfig.maxTicketsPerUser || 3, + maxLength: 2, + placeholder: '3', + validate: (raw) => { + const value = parseInt(raw, 10); + if (Number.isNaN(value) || value < 1 || value > 10) { + return 'Max tickets must be a whole number between **1** and **10**.'; + } + return null; + }, + onSave: async (raw) => { + const newMax = parseInt(raw, 10); + guildConfig.maxTicketsPerUser = newMax; + await setGuildConfig(client, guildId, guildConfig); + return successEmbed('Max Tickets Updated', `Users can now have at most **${newMax}** open ticket${newMax !== 1 ? 's' : ''} at a time.`); + }, }); +} - collector.on('end', (collected, reason) => { - if (reason === 'time' && collected.size === 0) { - replyUserError(selectInteraction, { - type: ErrorTypes.RATE_LIMIT, - message: 'No channel selected. No changes were made.', - }).catch(() => {}); - } +async function handleStaffRole(selectInteraction, rootInteraction, guildConfig, guildId, client) { + await runSelectFlow({ + selectInteraction, rootInteraction, guildConfig, guildId, client, + customId: 'ticket_cfg_staff_role', + componentType: ComponentType.RoleSelect, + menu: new RoleSelectMenuBuilder().setCustomId('ticket_cfg_staff_role').setPlaceholder('Select the staff role...').setMaxValues(1), + embedTitle: 'πŸ›‘οΈ Change Staff Role', + embedDescription: `**Current:** ${guildConfig.ticketStaffRoleId ? `<@&${guildConfig.ticketStaffRoleId}>` : '\`Not set\`'}\n\nSelect the role that should have staff access to manage tickets.`, + getSelected: (i) => i.roles.first(), + onSave: async (role) => { + guildConfig.ticketStaffRoleId = role.id; + await setGuildConfig(client, guildId, guildConfig); + return successEmbed('Staff Role Updated', `Staff role set to ${role}.`); + }, + timeoutMessage: 'No role was selected. The staff role was not changed.', }); } -async function handleTranscriptChannel(selectInteraction, rootInteraction, guildConfig, guildId, client) { - await selectInteraction.deferUpdate(); - - const channelSelect = new ChannelSelectMenuBuilder() - .setCustomId('ticket_cfg_transcript_channel') - .setPlaceholder('Select a channel...') - .addChannelTypes(ChannelType.GuildText) - .setMaxValues(1); - - await selectInteraction.followUp({ - embeds: [ - new EmbedBuilder() - .setTitle('πŸ“œ Select Transcript Channel') - .setDescription('Choose where auto-generated transcripts will be sent when tickets are deleted.') - .setColor(getColor('info')) - ], - components: [new ActionRowBuilder().addComponents(channelSelect)], - flags: MessageFlags.Ephemeral +async function handleOpenCategory(selectInteraction, rootInteraction, guildConfig, guildId, client) { + await runSelectFlow({ + selectInteraction, rootInteraction, guildConfig, guildId, client, + customId: 'ticket_cfg_open_cat', + componentType: ComponentType.ChannelSelect, + menu: categorySelectMenu('ticket_cfg_open_cat', 'Select a category...'), + embedTitle: 'πŸ“ Change Open Tickets Category', + embedDescription: `**Current:** ${guildConfig.ticketCategoryId ? `<#${guildConfig.ticketCategoryId}>` : '\`Not set\`'}\n\nSelect the category where new tickets will be created.`, + getSelected: (i) => i.channels.first(), + onSave: async (category) => { + guildConfig.ticketCategoryId = category.id; + await setGuildConfig(client, guildId, guildConfig); + return successEmbed('Open Category Updated', `New tickets will now be created in **${category.name}**.`); + }, + timeoutMessage: 'No category was selected. The setting was not changed.', }); +} - const collector = rootInteraction.channel.createMessageComponentCollector({ +async function handleClosedCategory(selectInteraction, rootInteraction, guildConfig, guildId, client) { + await runSelectFlow({ + selectInteraction, rootInteraction, guildConfig, guildId, client, + customId: 'ticket_cfg_closed_cat', componentType: ComponentType.ChannelSelect, - filter: i => i.user.id === selectInteraction.user.id && i.customId === 'ticket_cfg_transcript_channel', - time: 60_000, - max: 1 + menu: categorySelectMenu('ticket_cfg_closed_cat', 'Select a category...'), + embedTitle: 'πŸ“‚ Change Closed Tickets Category', + embedDescription: `**Current:** ${guildConfig.ticketClosedCategoryId ? `<#${guildConfig.ticketClosedCategoryId}>` : '\`Not set\`'}\n\nSelect the category where closed tickets will be moved.`, + getSelected: (i) => i.channels.first(), + onSave: async (category) => { + guildConfig.ticketClosedCategoryId = category.id; + await setGuildConfig(client, guildId, guildConfig); + return successEmbed('Closed Category Updated', `Closed tickets will now be moved to **${category.name}**.`); + }, + timeoutMessage: 'No category was selected. The setting was not changed.', }); +} - collector.on('collect', async channelInteraction => { - await channelInteraction.deferUpdate(); - const channel = channelInteraction.channels.first(); - - guildConfig.ticketTranscriptChannelId = channel.id; - await setGuildConfig(client, guildId, guildConfig); - - await channelInteraction.followUp({ - embeds: [successEmbed('Transcript Channel Updated', `Transcripts will be sent to ${channel}`)], - flags: MessageFlags.Ephemeral - }); - - await refreshDashboard(rootInteraction, guildConfig, guildId, client); +async function handleLogsChannel(selectInteraction, rootInteraction, guildConfig, guildId, client) { + await runSelectFlow({ + selectInteraction, rootInteraction, guildConfig, guildId, client, + customId: 'ticket_cfg_logs_channel', + componentType: ComponentType.ChannelSelect, + menu: channelSelectMenu('ticket_cfg_logs_channel', 'Select a channel...'), + embedTitle: '🎫 Select Ticket Logs Channel', + embedDescription: 'Choose where ticket feedback, lifecycle events (open, close, claim, etc.), and other logs will be sent.', + getSelected: (i) => i.channels.first(), + onSave: async (channel) => { + guildConfig.ticketLogsChannelId = channel.id; + await setGuildConfig(client, guildId, guildConfig); + return successEmbed('Logs Channel Updated', `Ticket logs will be sent to ${channel}`); + }, + timeoutMessage: 'No channel selected. No changes were made.', }); +} - collector.on('end', (collected, reason) => { - if (reason === 'time' && collected.size === 0) { - replyUserError(selectInteraction, { - type: ErrorTypes.RATE_LIMIT, - message: 'No channel selected. No changes were made.', - }).catch(() => {}); - } +async function handleTranscriptChannel(selectInteraction, rootInteraction, guildConfig, guildId, client) { + await runSelectFlow({ + selectInteraction, rootInteraction, guildConfig, guildId, client, + customId: 'ticket_cfg_transcript_channel', + componentType: ComponentType.ChannelSelect, + menu: channelSelectMenu('ticket_cfg_transcript_channel', 'Select a channel...'), + embedTitle: 'πŸ“œ Select Transcript Channel', + embedDescription: 'Choose where auto-generated transcripts will be sent when tickets are deleted.', + getSelected: (i) => i.channels.first(), + onSave: async (channel) => { + guildConfig.ticketTranscriptChannelId = channel.id; + await setGuildConfig(client, guildId, guildConfig); + return successEmbed('Transcript Channel Updated', `Transcripts will be sent to ${channel}`); + }, + timeoutMessage: 'No channel selected. No changes were made.', }); } -async function handleCheckUser(selectInteraction, rootInteraction, guildConfig, guildId, client) { - await selectInteraction.deferUpdate(); - - const userSelect = new UserSelectMenuBuilder() - .setCustomId('ticket_cfg_check_user') - .setPlaceholder('Select a user to check...') - .setMaxValues(1); +async function handleDmOnClose(btnInteraction, rootInteraction, guildConfig, guildId, client) { + await btnInteraction.deferUpdate(); - const row = new ActionRowBuilder().addComponents(userSelect); + const newState = guildConfig.dmOnClose === false; + guildConfig.dmOnClose = newState; + await setGuildConfig(client, guildId, guildConfig); - await selectInteraction.followUp({ - embeds: [ - new EmbedBuilder() - .setTitle('Check User Tickets') - .setDescription('Select a user to view their current open ticket count.') - .setColor(getColor('info')), - ], - components: [row], + await btnInteraction.followUp({ + embeds: [successEmbed('DM on Close Updated', `Users will **${newState ? 'now' : 'no longer'}** receive a DM when their ticket is closed.`)], flags: MessageFlags.Ephemeral, }); - const userCollector = rootInteraction.channel.createMessageComponentCollector({ - componentType: ComponentType.UserSelect, - filter: i => - i.user.id === selectInteraction.user.id && i.customId === 'ticket_cfg_check_user', - time: 60_000, - max: 1, - }); - - userCollector.on('collect', async userInteraction => { - await userInteraction.deferUpdate(); - const targetUser = userInteraction.users.first(); - const maxTickets = guildConfig.maxTicketsPerUser || 3; - const openCount = await getUserTicketCount(guildId, targetUser.id); - const atLimit = openCount >= maxTickets; - - await userInteraction.followUp({ - embeds: [ - new EmbedBuilder() - .setTitle(`Ticket Check β€” ${targetUser.username}`) - .setDescription( - `**Open Tickets:** ${openCount} / ${maxTickets}\n` + - `**Remaining:** ${Math.max(0, maxTickets - openCount)}\n\n` + - (atLimit - ? '⚠️ This user has reached their ticket limit.' - : 'βœ… This user can still open more tickets.'), - ) - .setColor(atLimit ? getColor('error') : getColor('success')) - .setThumbnail(targetUser.displayAvatarURL({ size: 64 })) - .setTimestamp(), - ], - flags: MessageFlags.Ephemeral, - }); - }); - - userCollector.on('end', (collected, reason) => { - if (reason === 'time' && collected.size === 0) { - replyUserError(selectInteraction, { - type: ErrorTypes.RATE_LIMIT, - message: 'No user was selected.', - }).catch(() => {}); - } - }); + await refreshDashboard(rootInteraction, guildConfig, guildId, client); } async function handleRepostPanel(btnInteraction, rootInteraction, guildConfig, guildId, client) { @@ -879,55 +487,48 @@ async function handleRepostPanel(btnInteraction, rootInteraction, guildConfig, g const panelStatus = await getTicketPanelStatus(client, rootInteraction.guild, guildConfig); if (panelStatus.exists) { - await btnInteraction.followUp({ - embeds: [infoEmbed('Panel Already Active', 'The ticket panel is already posted in the configured channel.')], - flags: MessageFlags.Ephemeral, - }).catch(() => {}); + await btnInteraction + .followUp({ embeds: [infoEmbed('Panel Already Active', 'The ticket panel is already posted in the configured channel.')], flags: MessageFlags.Ephemeral }) + .catch(() => {}); await refreshDashboard(rootInteraction, guildConfig, guildId, client); return; } const sentPanel = await repostTicketPanel(client, rootInteraction.guild, guildConfig, guildId); - await btnInteraction.followUp({ - embeds: [ - successEmbed( - 'Panel Reposted', - `A new ticket panel was posted in <#${guildConfig.ticketPanelChannelId}>.${ - sentPanel.url ? `\n[Open panel message](${sentPanel.url})` : '' - }`, - ), - ], - flags: MessageFlags.Ephemeral, - }).catch(() => {}); + await btnInteraction + .followUp({ + embeds: [ + successEmbed( + 'Panel Reposted', + `A new ticket panel was posted in <#${guildConfig.ticketPanelChannelId}>.${sentPanel.url ? `\n[Open panel message](${sentPanel.url})` : ''}`, + ), + ], + flags: MessageFlags.Ephemeral, + }) + .catch(() => {}); await refreshDashboard(rootInteraction, guildConfig, guildId, client); } async function handleDeleteSystem(btnInteraction, rootInteraction, guildConfig, guildId, client) { - const deleteModal = new ModalBuilder() - .setCustomId('ticket_delete_confirm_modal') - .setTitle('Delete Ticket System') - .addComponents( - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('delete_confirmation') - .setLabel('Type "DELETE" to confirm') - .setStyle(TextInputStyle.Short) - .setPlaceholder('DELETE') - .setMaxLength(6) - .setMinLength(6) - .setRequired(true) - ) - ); + const deleteModal = new ModalBuilder().setCustomId('ticket_delete_confirm_modal').setTitle('Delete Ticket System').addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('delete_confirmation') + .setLabel('Type "DELETE" to confirm') + .setStyle(TextInputStyle.Short) + .setPlaceholder('DELETE') + .setMaxLength(6) + .setMinLength(6) + .setRequired(true), + ), + ); await btnInteraction.showModal(deleteModal); const submitted = await btnInteraction - .awaitModalSubmit({ - filter: i => i.customId === 'ticket_delete_confirm_modal' && i.user.id === btnInteraction.user.id, - time: 120_000, - }) + .awaitModalSubmit({ filter: (i) => i.customId === 'ticket_delete_confirm_modal' && i.user.id === btnInteraction.user.id, time: 120_000 }) .catch(() => null); if (!submitted) { @@ -935,9 +536,7 @@ async function handleDeleteSystem(btnInteraction, rootInteraction, guildConfig, return; } - const confirmation = submitted.fields.getTextInputValue('delete_confirmation').trim(); - - if (confirmation !== 'DELETE') { + if (submitted.fields.getTextInputValue('delete_confirmation').trim() !== 'DELETE') { await replyUserError(submitted, { type: ErrorTypes.UNKNOWN, message: 'You must type "DELETE" exactly to confirm deletion.' }); await refreshDashboard(rootInteraction, guildConfig, guildId, client); return; @@ -945,18 +544,6 @@ async function handleDeleteSystem(btnInteraction, rootInteraction, guildConfig, await submitted.deferUpdate(); - const keysToDelete = [ - 'ticketPanelChannelId', - 'ticketPanelMessageId', - 'ticketStaffRoleId', - 'ticketCategoryId', - 'ticketClosedCategoryId', - 'ticketPanelMessage', - 'ticketButtonLabel', - 'maxTicketsPerUser', - 'dmOnClose', - ]; - if (guildConfig.ticketPanelChannelId) { try { const panelChannel = await client.guilds.cache.get(guildId)?.channels.fetch(guildConfig.ticketPanelChannelId).catch(() => null); @@ -965,14 +552,9 @@ async function handleDeleteSystem(btnInteraction, rootInteraction, guildConfig, const panelMessage = await panelChannel.messages.fetch(guildConfig.ticketPanelMessageId).catch(() => null); if (panelMessage) await panelMessage.delete().catch(() => {}); } else { - const messages = await panelChannel.messages.fetch({ limit: 50 }).catch(() => null); - if (messages) { - const found = messages.find( - m => m.author.id === client.user.id && messageHasButtonCustomId(m, 'create_ticket'), - ); - if (found) await found.delete().catch(() => {}); - } + const found = messages?.find((m) => m.author.id === client.user.id && messageHasButtonCustomId(m, 'create_ticket')); + if (found) await found.delete().catch(() => {}); } } } catch (panelDeleteError) { @@ -982,39 +564,96 @@ async function handleDeleteSystem(btnInteraction, rootInteraction, guildConfig, try { const { pgConfig } = await import('../../../config/database/postgres.js'); - if (client.db?.db?.pool && typeof client.db.db.isAvailable === 'function' && client.db.db.isAvailable()) { - await client.db.db.pool.query( - `DELETE FROM ${pgConfig.tables.tickets} WHERE guild_id = $1`, - [guildId] - ); + if (client.db?.db?.pool && client.db.db.isAvailable?.()) { + await client.db.db.pool.query(`DELETE FROM ${pgConfig.tables.tickets} WHERE guild_id = $1`, [guildId]); } } catch (ticketDeleteError) { logger.warn('Could not clear ticket records from database:', ticketDeleteError.message); } - for (const key of keysToDelete) { + for (const key of [ + 'ticketPanelChannelId', 'ticketPanelMessageId', 'ticketStaffRoleId', 'ticketCategoryId', + 'ticketClosedCategoryId', 'ticketPanelMessage', 'ticketButtonLabel', 'maxTicketsPerUser', 'dmOnClose', + ]) { delete guildConfig[key]; } await setGuildConfig(client, guildId, guildConfig); await submitted.followUp({ - embeds: [ - successEmbed( - 'βœ… Ticket System Deleted', - 'All ticket system configuration has been cleared. Run `/ticket setup` to set it up again.', - ), - ], + embeds: [successEmbed('βœ… Ticket System Deleted', 'All ticket system configuration has been cleared. Run `/ticket setup` to set it up again.')], flags: MessageFlags.Ephemeral, }); await InteractionHelper.safeEditReply(rootInteraction, { - embeds: [ - new EmbedBuilder() - .setTitle('Ticket System Deleted') - .setDescription('The ticket system configuration has been cleared.') - .setColor(getColor('error')) - .setTimestamp(), - ], + embeds: [new EmbedBuilder().setTitle('Ticket System Deleted').setDescription('The ticket system configuration has been cleared.').setColor(getColor('error')).setTimestamp()], components: [], }).catch(() => {}); -} \ No newline at end of file +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +const SELECT_HANDLERS = { + panel_message: handlePanelMessage, + button_label: handleButtonLabel, + open_category: handleOpenCategory, + closed_category: handleClosedCategory, + max_tickets: handleMaxTickets, + logs_channel: handleLogsChannel, + transcript_channel: handleTranscriptChannel, +}; + +export default { + prefixOnly: false, + async execute(interaction, config, client) { + try { + const guildId = interaction.guild.id; + const guildConfig = await getGuildConfig(client, guildId); + + if (!guildConfig.ticketPanelChannelId) { + throw new TitanBotError( + 'Ticket system not configured', + ErrorTypes.CONFIGURATION, + 'The ticket system has not been set up yet. Run `/ticket setup` first to configure it.', + ); + } + + const panelStatus = await getTicketPanelStatus(client, interaction.guild, guildConfig); + if (panelStatus.recoveredId) { + await persistPanelMessageId(client, guildId, guildConfig, panelStatus.recoveredId); + } + + const ticketStats = await getGuildTicketStats(guildId); + + await startDashboardSession({ + interaction, + embeds: [buildDashboardEmbed(guildConfig, interaction.guild, panelStatus, ticketStats)], + components: [buildButtonRow(guildConfig, guildId, false, panelStatus), new ActionRowBuilder().addComponents(buildSelectMenu(guildId))], + selectMenuId: `ticket_config_${guildId}`, + buttonMatcher: (customId) => + [ + `ticket_cfg_repost_${guildId}`, + `ticket_cfg_dm_toggle_${guildId}`, + `ticket_cfg_staff_role_btn_${guildId}`, + `ticket_cfg_delete_${guildId}`, + ].includes(customId), + onSelect: async (selectInteraction) => { + const handler = SELECT_HANDLERS[selectInteraction.values[0]]; + if (handler) await handler(selectInteraction, interaction, guildConfig, guildId, client); + }, + onButton: async (btnInteraction) => { + const id = btnInteraction.customId; + if (id === `ticket_cfg_repost_${guildId}`) await handleRepostPanel(btnInteraction, interaction, guildConfig, guildId, client); + else if (id === `ticket_cfg_dm_toggle_${guildId}`) await handleDmOnClose(btnInteraction, interaction, guildConfig, guildId, client); + else if (id === `ticket_cfg_staff_role_btn_${guildId}`) await handleStaffRole(btnInteraction, interaction, guildConfig, guildId, client); + else if (id === `ticket_cfg_delete_${guildId}`) await handleDeleteSystem(btnInteraction, interaction, guildConfig, guildId, client); + }, + }); + } catch (error) { + if (error instanceof TitanBotError) throw error; + logger.error('Unexpected error in ticket_config:', error); + throw new TitanBotError(`Ticket config failed: ${error.message}`, ErrorTypes.UNKNOWN, 'Failed to open the ticket configuration dashboard.'); + } + }, +}; From 2da0a7818ef7391536906e1190d01138176a12ba Mon Sep 17 00:00:00 2001 From: realkyx29-design Date: Mon, 10 Aug 2026 09:30:27 -0700 Subject: [PATCH 03/13] Refactor ticket command to simplify setup process --- src/commands/Ticket/ticket.js | 366 +++++++++++----------------------- 1 file changed, 112 insertions(+), 254 deletions(-) diff --git a/src/commands/Ticket/ticket.js b/src/commands/Ticket/ticket.js index 982747b118..7e2935a93e 100644 --- a/src/commands/Ticket/ticket.js +++ b/src/commands/Ticket/ticket.js @@ -1,6 +1,6 @@ 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 { SlashCommandBuilder, PermissionFlagsBits, ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags } from 'discord.js'; +import { createEmbed, successEmbed } from '../../utils/embeds.js'; import { getGuildConfig, setGuildConfig } from '../../services/config/guildConfig.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { logger } from '../../utils/logger.js'; @@ -10,290 +10,148 @@ import ticketConfig from './modules/ticket_dashboard.js'; export default { data: new SlashCommandBuilder() - .setName("ticket") + .setName('ticket') .setDescription("Manages the server's ticket system.") .setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels) .addSubcommand((subcommand) => subcommand - .setName("setup") - .setDescription( - "Sets up the ticket creation panel in a specified channel.", - ) + .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), + 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), + 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).", - ) + .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), - ) + .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), + 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), - ), + .addBooleanOption((option) => option.setName('dm_on_close').setDescription('Send DM to user when their ticket is closed (default: true)').setRequired(false)), ) - .addSubcommand((subcommand) => - subcommand - .setName("dashboard") - .setDescription("Open the interactive ticket system dashboard"), - ), - category: "ticket", + .addSubcommand((subcommand) => subcommand.setName('dashboard').setDescription('Open the interactive ticket system dashboard')), + category: 'ticket', async execute(interaction, config, client) { const deferred = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); - if (!deferred) { - return; - } + 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.' }); + if (!interaction.member.permissions.has(PermissionFlagsBits.ManageChannels)) { + logger.warn('Ticket command permission denied', { userId: interaction.user.id, guildId: interaction.guildId, commandName: 'ticket' }); + return replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need the `Manage Channels` permission for this action.' }); } const subcommand = interaction.options.getSubcommand(); - if (subcommand === "dashboard") { + 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.` }); - } - - 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; + if (subcommand === 'setup') { + return runSetup(interaction, client); + } + }, +}; + +async function runSetup(interaction, client) { + const existingConfig = await getGuildConfig(client, interaction.guildId); + if (existingConfig?.ticketPanelChannelId) { + return 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.`, + }); + } - const setupEmbed = createEmbed({ - title: "Support Tickets", -description: panelMessage, - color: getColor('info') + 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 ticketButton = new ActionRowBuilder().addComponents( + new ButtonBuilder().setCustomId('create_ticket').setLabel(buttonLabel).setStyle(ButtonStyle.Primary).setEmoji('πŸ“©'), + ); + + try { + const sentPanel = await panelChannel.send({ + embeds: [createEmbed({ title: 'Support Tickets', description: panelMessage, color: getColor('info') })], + components: [ticketButton], + }); + + const logMeta = { + guildId: interaction.guildId, + categoryId: categoryChannel?.id, + closedCategoryId: closedCategoryChannel?.id, + staffRoleId: staffRole?.id, + maxTickets: maxTicketsPerUser, + dmOnClose, + }; + + if (client.db) { + Object.assign(existingConfig, { + ticketCategoryId: categoryChannel?.id || null, + ticketClosedCategoryId: closedCategoryChannel?.id || null, + ticketStaffRoleId: staffRole?.id || null, + ticketPanelChannelId: panelChannel.id, + ticketPanelMessageId: sentPanel?.id || null, + ticketPanelMessage: panelMessage, + ticketButtonLabel: buttonLabel, + maxTicketsPerUser, + dmOnClose, }); - 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; - - await setGuildConfig(client, interaction.guildId, currentConfig); - logger.info('Ticket configuration saved', { - guildId: interaction.guildId, - categoryId: categoryChannel?.id, - closedCategoryId: closedCategoryChannel?.id, - staffRoleId: staffRole?.id, - maxTickets: maxTicketsPerUser, - dmOnClose: dmOnClose, - }); - } else { - logger.error('Ticket setup: database unavailable, panel sent but configuration was NOT saved', { - guildId: interaction.guildId, - }); - } - - let successMessage = `The ticket creation panel has been sent to ${panelChannel}.`; - - if (categoryChannel) { - successMessage += `New tickets will be created in the **${categoryChannel.name}** category.`; - } else { - successMessage += 'New tickets will be created in a new "Tickets" category.'; - } - - if (closedCategoryChannel) { - successMessage += `Closed tickets will be moved to **${closedCategoryChannel.name}**.`; - } - - if (staffRole) { - successMessage += `**${staffRole.name}** role will have access to tickets.`; - } - - successMessage += `\n\n**Max Tickets Per User:** ${maxTicketsPerUser}\n**DM on Close:** ${dmOnClose ? 'Enabled' : 'Disabled'}`; - - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - "Ticket Panel Set Up", - successMessage, - ), - ], - }); - - logger.info('Ticket panel setup completed', { - userId: interaction.user.id, - userTag: interaction.user.tag, - guildId: interaction.guildId, - panelChannelId: panelChannel.id, - categoryId: categoryChannel?.id, - closedCategoryId: closedCategoryChannel?.id, - staffRoleId: staffRole?.id, - maxTickets: maxTicketsPerUser, - dmOnClose: dmOnClose, - commandName: 'ticket_setup' - }); - - const logEmbed = createEmbed({ - title: "Ticket System Setup (Configuration Log)", - description: `The ticket panel was set up in ${panelChannel} by ${interaction.user}.`, - color: getColor('warning') - }) - .addFields( - { - name: "Panel Channel", - value: panelChannel.toString(), - inline: true, - }, - { - name: "Ticket Category", - value: categoryChannel - ? categoryChannel.toString() - : "None specified.", - inline: true, - }, - { - name: "Closed Category", - value: closedCategoryChannel - ? closedCategoryChannel.toString() - : "None specified.", - inline: true, - }, - { - name: "Staff Role", - value: staffRole - ? staffRole.toString() - : "None specified.", - inline: true, - }, - { - name: "Max Tickets Per User", - value: maxTicketsPerUser.toString(), - inline: true, - }, - { - name: "DM on Close", - value: dmOnClose ? 'Enabled' : 'Disabled', - inline: true, - }, - { - name: "Moderator", - value: `${interaction.user.tag} (${interaction.user.id})`, - inline: false, - }, - ); + await setGuildConfig(client, interaction.guildId, existingConfig); + logger.info('Ticket configuration saved', logMeta); + } else { + logger.error('Ticket setup: database unavailable, panel sent but configuration was NOT saved', { guildId: interaction.guildId }); + } - } 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 - }); - }); - } else { - await handleInteractionError(interaction, error, { - commandName: 'ticket_setup', - source: 'ticket_setup_command' - }); + const summaryLines = [ + `The ticket creation panel has been sent to ${panelChannel}.`, + categoryChannel ? `New tickets will be created in the **${categoryChannel.name}** category.` : 'New tickets will be created in a new "Tickets" category.', + closedCategoryChannel ? `Closed tickets will be moved to **${closedCategoryChannel.name}**.` : null, + staffRole ? `**${staffRole.name}** role will have access to tickets.` : null, + `\n\n**Max Tickets Per User:** ${maxTicketsPerUser}\n**DM on Close:** ${dmOnClose ? 'Enabled' : 'Disabled'}`, + ].filter(Boolean); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [successEmbed('Ticket Panel Set Up', summaryLines.join(''))], + }); + + logger.info('Ticket panel setup completed', { + userId: interaction.user.id, + userTag: interaction.user.tag, + panelChannelId: panelChannel.id, + commandName: 'ticket_setup', + ...logMeta, + }); + } 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 })); + } else { + await handleInteractionError(interaction, error, { commandName: 'ticket_setup', source: 'ticket_setup_command' }); + } + } +} } } } } -}; \ No newline at end of file +}; From f4961adc80c2c3f30ca9ab345c228fafb8fb4cca Mon Sep 17 00:00:00 2001 From: realkyx29-design Date: Mon, 10 Aug 2026 11:19:40 -0700 Subject: [PATCH 04/13] Change bot status from 'online' to 'idle' --- 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 a791cae5f0..3692051604 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: "idle", // Activity lines shown under the bot name. // `type` number mapping from Discord: @@ -24,7 +24,7 @@ export const botConfig = { activities: [ { name: "Custom Status", // required by Discord API, not shown in the client - state: "Gstar Studio", // this is what people actually see + state: "Chicago", // this is what people actually see type: 4, // Custom }, ], From eaa6e7e80d6e3ba060b283419d1fce271630e029 Mon Sep 17 00:00:00 2001 From: realkyx29-design Date: Mon, 10 Aug 2026 12:06:41 -0700 Subject: [PATCH 05/13] Update bot status from 'Chicago' to 'Test' --- 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 3692051604..6418e046ea 100644 --- a/src/config/bot.js +++ b/src/config/bot.js @@ -24,7 +24,7 @@ export const botConfig = { activities: [ { name: "Custom Status", // required by Discord API, not shown in the client - state: "Chicago", // this is what people actually see + state: "Test", // this is what people actually see type: 4, // Custom }, ], From 8772df656cd176c8cc15e343021736d53b48f464 Mon Sep 17 00:00:00 2001 From: "arena-ai-coding-agent[bot]" <298482267+arena-ai-coding-agent[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:09:22 +0000 Subject: [PATCH 06/13] Add YouTube support to the music system (#1) - Remove the YouTube URL block in /play so youtube.com, youtu.be, music.youtube.com, Shorts, and playlist links resolve through Lavalink - Normalize bare streaming URLs (e.g. 'youtu.be/abc') to https URLs so Lavalink loads them directly instead of searching - Add a 'source' option to /play to search YouTube, YouTube Music, Spotify, SoundCloud, or Deezer, and support Lavalink prefixes typed directly in the query (ytsearch:, ytmsearch:, ...) - Show the track/playlist source (e.g. 'Source: YouTube') in play and now-playing embeds - Handle LOAD_FAILED / NO_MATCHES results with clear error messages - Update README and .env.example docs Co-authored-by: realkyx29-design <247477397+realkyx29-design@users.noreply.github.com> Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .env.example | 3 + README.md | 7 +- src/commands/Music/play.js | 22 +++++- src/services/music/musicActions.js | 52 +++++++++---- src/services/music/musicEmbeds.js | 4 +- src/services/music/sources.js | 121 +++++++++++++++++++++++++++++ 6 files changed, 188 insertions(+), 21 deletions(-) create mode 100644 src/services/music/sources.js diff --git a/.env.example b/.env.example index c2eae4ad28..f36b3cb843 100644 --- a/.env.example +++ b/.env.example @@ -63,6 +63,9 @@ POSTGRES_RESTORE_URL= # LAVALINK_PASSWORD=youshallnotpass # LAVALINK_SECURE=false # LAVALINK_NAME=Main +# Default search platform for /play when no source is chosen. +# Options: ytsearch (YouTube), ytmsearch (YouTube Music), spsearch (Spotify), +# scsearch (SoundCloud), dzsearch (Deezer). URLs are always resolved directly. LAVALINK_SEARCH_PLATFORM=ytmsearch LAVALINK_REST_VERSION=v4 diff --git a/README.md b/README.md index 700ac20739..38620cdf3a 100644 --- a/README.md +++ b/README.md @@ -84,8 +84,9 @@ TitanBot offers a complete suite of tools for Discord server management and comm ### Music - **24/7 Mode** - Play music 24/7 -- **Interative Button System** - Manage music through buttons -- **Supports EVERY platform** - Supports spotify, deezer, youtube, apple music +- **Interactive Button System** - Manage music through buttons +- **YouTube Support** - Play YouTube videos, Shorts, and playlists by link, or search YouTube / YouTube Music +- **Multi-Platform** - Also plays spotify, deezer, soundcloud, and apple music links @@ -141,7 +142,7 @@ Music uses [Lavalink v4](https://github.com/lavalink-devs/Lavalink) via [Riffy]( ``` Remove or rename `lavalink/nodes.json` so the bot falls back to those env vars. 3. Override nodes inline with `LAVALINK_NODES` (JSON array) or point at another file with `LAVALINK_NODES_FILE`. -4. Use `/play ` from a voice channel, or `/join` to connect without playing. Prefix shortcuts: `join`, `np`, `leave`, `pause`, `resume`, `skip`, `stop`, `volume <0-100>`, or `music `. Use `/nowplaying` and `/queue` for status; `/music` for loop, shuffle, seek, and other controls. +4. Use `/play ` from a voice channel, or `/join` to connect without playing. Paste any **YouTube** link (`youtube.com/watch`, `youtu.be`, `music.youtube.com`, Shorts, and playlists all work) and it plays directly. For searches, `/play` defaults to YouTube Music; use the `source` option to search **YouTube**, Spotify, SoundCloud, or Deezer instead (or type a prefix like `ytsearch:` / `ytmsearch:` directly into the query). Prefix shortcuts: `join`, `np`, `leave`, `pause`, `resume`, `skip`, `stop`, `volume <0-100>`, or `music `. Use `/nowplaying` and `/queue` for status; `/music` for loop, shuffle, seek, and other controls. ### Using GitHub Container Registry diff --git a/src/commands/Music/play.js b/src/commands/Music/play.js index 193b2d4d69..0147cd0f82 100644 --- a/src/commands/Music/play.js +++ b/src/commands/Music/play.js @@ -1,20 +1,36 @@ import { SlashCommandBuilder, MessageFlags } from 'discord.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { playQuery, replyMusicSuccess } from '../../services/music/musicActions.js'; +import { SEARCH_SOURCE_CHOICES } from '../../services/music/sources.js'; export default { slashOnly: true, category: 'Music', data: new SlashCommandBuilder() .setName('play') - .setDescription('Play a song or add it to the queue') + .setDescription('Play a song, search, or paste a link (YouTube, Spotify, etc.)') .addStringOption((opt) => - opt.setName('query').setDescription('Song name or URL').setRequired(true), + opt + .setName('query') + .setDescription('Song name, search query, or URL (YouTube, Spotify, SoundCloud...)') + .setRequired(true), + ) + .addStringOption((opt) => + opt + .setName('source') + .setDescription('Where to search (ignored when the query is a URL)') + .setRequired(false) + .addChoices( + { name: 'Auto (default)', value: 'auto' }, + ...SEARCH_SOURCE_CHOICES, + ), ), async execute(interaction, config, client) { await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); - const result = await playQuery(client, interaction, interaction.options.getString('query')); + const query = interaction.options.getString('query'); + const source = interaction.options.getString('source') || 'auto'; + const result = await playQuery(client, interaction, query, source); await replyMusicSuccess(interaction, result.embed); }, }; diff --git a/src/services/music/musicActions.js b/src/services/music/musicActions.js index 206da64956..bf96973264 100644 --- a/src/services/music/musicActions.js +++ b/src/services/music/musicActions.js @@ -11,8 +11,12 @@ import { getQueuePageSize, } from './musicEmbeds.js'; import { refreshPlayerMessage } from './playerHandler.js'; - -const YOUTUBE_URL_PATTERN = /(?:youtube\.com|youtu\.be)/i; +import { + getSourceName, + getTrackSourceName, + normalizeQueryUrl, + parseSearchPrefix, +} from './sources.js'; export function getPlayer(client, guildId) { return client.riffy?.players?.get(guildId) || null; @@ -117,19 +121,21 @@ export async function joinVoiceChannel(client, interaction) { ); } -export async function playQuery(client, interaction, query) { - if (YOUTUBE_URL_PATTERN.test(query)) { - throw new TitanBotError( - 'YouTube URL blocked', - ErrorTypes.USER_INPUT, - 'YouTube links are not supported. Try a song name instead.', - ); - } +export async function playQuery(client, interaction, query, source = null) { + // Support both the /play `source` option and Lavalink prefixes typed + // directly into the query (e.g. "ytsearch:..."). + const { source: prefixSource, query: unprefixedQuery } = parseSearchPrefix(query); + const effectiveSource = prefixSource || (source && source !== 'auto' ? source : null); + + const identifier = normalizeQueryUrl(unprefixedQuery); + const isUrlQuery = /^https?:\/\//i.test(identifier); const { player, guildData } = await ensurePlayer(client, interaction); const result = await client.riffy.resolve({ - query, + query: identifier, + // Only used for searches β€” URLs are resolved directly by Lavalink. + source: isUrlQuery ? null : effectiveSource, requester: interaction.user, }); @@ -153,10 +159,13 @@ export async function playQuery(client, interaction, query) { player.play(); } + const playlistSource = getSourceName(identifier) || (isUrlQuery ? 'Link' : null); + const sourceLine = playlistSource ? `\nSource: **${playlistSource}**` : ''; + return { embed: successEmbed( 'Playlist Added', - `**${playlistInfo?.name || 'Playlist'}**\nAdded ${added} of ${tracks.length} track(s).${skipped ? ` Skipped ${skipped} duplicate(s).` : ''}`, + `**${playlistInfo?.name || 'Playlist'}**\nAdded ${added} of ${tracks.length} track(s).${skipped ? ` Skipped ${skipped} duplicate(s).` : ''}${sourceLine}`, ), }; } @@ -190,16 +199,31 @@ export async function playQuery(client, interaction, query) { player.play(); } + const trackSource = getTrackSourceName(track); + const sourceLine = trackSource ? `\nSource: **${trackSource}**` : ''; + return { embed: successEmbed( willPlayNow ? 'Now Playing' : 'Track Added', willPlayNow - ? `**${track.info.title}**\n${track.info.author}` - : `**${track.info.title}**\n${track.info.author}\nPosition: #${queuePosition} in queue`, + ? `**${track.info.title}**\n${track.info.author}${sourceLine}` + : `**${track.info.title}**\n${track.info.author}\nPosition: #${queuePosition} in queue${sourceLine}`, ), }; } + const normalizedLoadType = String(loadType || '').toUpperCase(); + if (normalizedLoadType === 'LOAD_FAILED') { + throw new TitanBotError( + 'Load failed', + ErrorTypes.USER_INPUT, + 'Could not load that. The link may be invalid, private, or unavailable.', + ); + } + if (normalizedLoadType === 'NO_MATCHES' || normalizedLoadType === 'EMPTY') { + throw new TitanBotError('No results', ErrorTypes.USER_INPUT, 'No results found for that query.'); + } + throw new TitanBotError('No results', ErrorTypes.USER_INPUT, `No results found. (loadType: ${loadType})`); } diff --git a/src/services/music/musicEmbeds.js b/src/services/music/musicEmbeds.js index e57a74abe0..db95737541 100644 --- a/src/services/music/musicEmbeds.js +++ b/src/services/music/musicEmbeds.js @@ -1,6 +1,7 @@ import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; import { createEmbed } from '../../utils/embeds.js'; import { getPaginationRow } from '../../utils/components.js'; +import { getTrackSourceName } from './sources.js'; const QUEUE_PAGE_SIZE = 10; @@ -57,9 +58,10 @@ export function buildNowPlayingEmbed(track, player, guildData) { const position = formatDuration(player?.position || 0); const duration = formatDuration(track?.info?.length || 0); + const source = getTrackSourceName(track); return createEmbed({ - title: 'Now Playing', + title: source ? `Now Playing (${source})` : 'Now Playing', description: track?.info?.title || 'Unknown track', color: 'primary', fields: [ diff --git a/src/services/music/sources.js b/src/services/music/sources.js new file mode 100644 index 0000000000..95b96324dc --- /dev/null +++ b/src/services/music/sources.js @@ -0,0 +1,121 @@ +// Music source detection + query normalization helpers. +// +// Lavalink resolves URLs directly (auto-detecting the platform), and treats +// anything else as a search on the configured search platform (e.g. ytmsearch). +// These helpers identify which platform a query/URL belongs to so the bot can +// label playback, normalize bare URLs, and forward Lavalink search prefixes. + +// Order matters: check the most specific host first (music.youtube.com is also +// matched by the generic youtube.com rule below it). +const SOURCE_PATTERNS = [ + { name: 'YouTube Music', pattern: /music\.youtube\.com/i }, + { name: 'YouTube', pattern: /(?:youtube\.com|youtu\.be)/i }, + { name: 'Spotify', pattern: /open\.spotify\.com/i }, + { name: 'SoundCloud', pattern: /soundcloud\.com/i }, + { name: 'Deezer', pattern: /deezer\.com/i }, + { name: 'Apple Music', pattern: /music\.apple\.com/i }, + { name: 'Bandcamp', pattern: /bandcamp\.com/i }, + { name: 'Twitch', pattern: /twitch\.tv/i }, + { name: 'Vimeo', pattern: /vimeo\.com/i }, +]; + +// Lavalink v4 track.info.sourceName -> friendly label. +const SOURCE_NAME_LABELS = { + youtube: 'YouTube', + ytmsearch: 'YouTube Music', + spotify: 'Spotify', + soundcloud: 'SoundCloud', + deezer: 'Deezer', + applemusic: 'Apple Music', + bandcamp: 'Bandcamp', + twitch: 'Twitch', + vimeo: 'Vimeo', + http: 'Direct URL', + local: 'Local', +}; + +// Bare URLs (no scheme) for known streaming hosts. Lavalink only treats a query +// as a URL when it starts with a scheme, so these get `https://` prepended. +const STREAMING_HOST_START = + /^(?:[\w-]+\.)*(?:youtube\.com|youtu\.be|open\.spotify\.com|soundcloud\.com|deezer\.com|music\.apple\.com|bandcamp\.com|twitch\.tv|vimeo\.com)(?:\/|$)/i; + +// Lavalink search prefixes the bot understands, both as a /play `source` option +// and typed directly into the query (e.g. `ytsearch:never gonna give you up`). +export const SEARCH_SOURCE_CHOICES = [ + { name: 'YouTube', value: 'ytsearch' }, + { name: 'YouTube Music', value: 'ytmsearch' }, + { name: 'Spotify', value: 'spsearch' }, + { name: 'SoundCloud', value: 'scsearch' }, + { name: 'Deezer', value: 'dzsearch' }, +]; + +const SEARCH_PREFIX_PATTERN = + /^(ytsearch|ytmsearch|spsearch|scsearch|dzsearch|amsearch|ymsearch|jiosaavnsearch|audiostacksearch):\s*(.*)$/i; + +export function getSourceName(value) { + if (typeof value !== 'string' || !value) { + return null; + } + for (const { name, pattern } of SOURCE_PATTERNS) { + if (pattern.test(value)) { + return name; + } + } + return null; +} + +export function getTrackSourceName(track) { + const raw = track?.info?.sourceName; + if (raw) { + const label = SOURCE_NAME_LABELS[String(raw).toLowerCase()]; + if (label) { + return label; + } + } + return getSourceName(track?.info?.uri); +} + +export function isYouTubeUrl(value) { + const source = getSourceName(value); + return source === 'YouTube' || source === 'YouTube Music'; +} + +export function isYouTubePlaylistUrl(value) { + return ( + typeof value === 'string' + && /(?:youtube\.com|youtu\.be)[^ ]*(?:\/playlist\?|\?.*[?&]list=)/i.test(value) + ); +} + +// Prepends a scheme to bare streaming URLs so Lavalink loads them as URLs +// instead of treating them as search text. Explicit web URLs and other +// schemes (e.g. spotify:track:...) are passed through untouched. +export function normalizeQueryUrl(query) { + if (typeof query !== 'string' || !query.trim()) { + return query; + } + const trimmed = query.trim(); + if (/^https?:\/\//i.test(trimmed)) { + return trimmed; + } + if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed)) { + return trimmed; + } + if (!/\s/.test(trimmed) && STREAMING_HOST_START.test(trimmed)) { + return `https://${trimmed}`; + } + return trimmed; +} + +// Splits a leading Lavalink search prefix out of a query (if present) so Riffy +// does not double-prefix it with the default search platform. +export function parseSearchPrefix(query) { + if (typeof query !== 'string') { + return { source: null, query }; + } + const match = SEARCH_PREFIX_PATTERN.exec(query.trim()); + if (match) { + return { source: match[1].toLowerCase(), query: match[2] }; + } + return { source: null, query }; +} From bd37a076f2362206b8aa2f6490e2d047e343ede6 Mon Sep 17 00:00:00 2001 From: realkyx29-design Date: Mon, 10 Aug 2026 12:40:29 -0700 Subject: [PATCH 07/13] Add banner command to display user profile banner --- .../modules/src/commands/Utility/banner.js | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 src/commands/Utility/modules/src/commands/Utility/banner.js diff --git a/src/commands/Utility/modules/src/commands/Utility/banner.js b/src/commands/Utility/modules/src/commands/Utility/banner.js new file mode 100644 index 0000000000..8cbe43b3bb --- /dev/null +++ b/src/commands/Utility/modules/src/commands/Utility/banner.js @@ -0,0 +1,57 @@ +import { SlashCommandBuilder } from 'discord.js'; +import { createEmbed, successEmbed } from '../../utils/embeds.js'; +import { logger } from '../../utils/logger.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; + +export default { + category: 'Utility', + data: new SlashCommandBuilder() + .setName('banner') + .setDescription("Display a user's profile banner") + .addUserOption((option) => + option + .setName('user') + .setDescription('The user whose banner you want to see (defaults to you)'), + ), + + async execute(interaction) { + const target = interaction.options.getUser('user') || interaction.user; + + // Banner data is only available on a fully fetched user. + let user = target; + try { + user = await interaction.client.users.fetch(target.id, { force: true }); + } catch (error) { + logger.warn(`Banner user fetch failed for ${target.id}: ${error?.message || error}`); + } + + const bannerUrl = user.bannerURL({ size: 2048, dynamic: true }); + + if (!bannerUrl) { + await InteractionHelper.safeReply(interaction, { + embeds: [successEmbed('No Banner', `**${user.username}** doesn't have a profile banner set.`)], + }); + logger.info('Banner command executed (no banner)', { + userId: interaction.user.id, + targetUserId: user.id, + guildId: interaction.guildId, + }); + return; + } + + const embed = createEmbed({ + title: `${user.username}'s Banner`, + description: `[Download Link](${bannerUrl})`, + color: 'info', + image: bannerUrl, + footer: `Requested by ${interaction.user.username}`, + }); + + await InteractionHelper.safeReply(interaction, { embeds: [embed] }); + logger.info('Banner command executed', { + userId: interaction.user.id, + targetUserId: user.id, + guildId: interaction.guildId, + }); + }, +}; From 94f8191386f699236eedd4fdd8b590483fe694a4 Mon Sep 17 00:00:00 2001 From: realkyx29-design Date: Mon, 10 Aug 2026 13:19:28 -0700 Subject: [PATCH 08/13] Updated project --- src/commands/Utility/{modules/src/commands/Utility => }/banner.js | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/commands/Utility/{modules/src/commands/Utility => }/banner.js (100%) diff --git a/src/commands/Utility/modules/src/commands/Utility/banner.js b/src/commands/Utility/banner.js similarity index 100% rename from src/commands/Utility/modules/src/commands/Utility/banner.js rename to src/commands/Utility/banner.js From 2ca78e715a55e583c1bb0b36ea6a13b68d8a76b7 Mon Sep 17 00:00:00 2001 From: realkyx29-design Date: Mon, 10 Aug 2026 13:35:44 -0700 Subject: [PATCH 09/13] Fixed bugs --- src/commands/Ticket/ticket.js | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/commands/Ticket/ticket.js b/src/commands/Ticket/ticket.js index 7e2935a93e..c55deac94e 100644 --- a/src/commands/Ticket/ticket.js +++ b/src/commands/Ticket/ticket.js @@ -149,9 +149,4 @@ async function runSetup(interaction, client) { await handleInteractionError(interaction, error, { commandName: 'ticket_setup', source: 'ticket_setup_command' }); } } -} - } - } - } - } -}; +}; \ No newline at end of file From ddf997b2b551ca71f1af462a6ed312fee336c95b Mon Sep 17 00:00:00 2001 From: realkyx29-design Date: Mon, 10 Aug 2026 15:09:13 -0700 Subject: [PATCH 10/13] just a lil ragebaiting update --- 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 6418e046ea..c15643c111 100644 --- a/src/config/bot.js +++ b/src/config/bot.js @@ -24,7 +24,7 @@ export const botConfig = { activities: [ { name: "Custom Status", // required by Discord API, not shown in the client - state: "Test", // this is what people actually see + state: "Dario is a bitch", // this is what people actually see type: 4, // Custom }, ], From c732b1a9c25190f50923108f803622569e5bdf32 Mon Sep 17 00:00:00 2001 From: realkyx29-design Date: Mon, 10 Aug 2026 16:12:28 -0700 Subject: [PATCH 11/13] Updated Stats --- 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 c15643c111..2c4ef362f1 100644 --- a/src/config/bot.js +++ b/src/config/bot.js @@ -24,7 +24,7 @@ export const botConfig = { activities: [ { name: "Custom Status", // required by Discord API, not shown in the client - state: "Dario is a bitch", // this is what people actually see + state: "Owned by Gstar Studios & larpingmemecoin", // this is what people actually see type: 4, // Custom }, ], From bf09764263520f5ee3d547c13a104867e4719d5d Mon Sep 17 00:00:00 2001 From: realkyx29-design <247477397+realkyx29-design@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:32:23 +0000 Subject: [PATCH 12/13] Organize and rename commands for clarity and consistency Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- README.md | 2 +- .../{app-admin.js => application-admin.js} | 6 +- .../Community/modules/app_dashboard.js | 2 +- .../{configWizard.js => config-wizard.js} | 2 +- src/commands/Core/help.js | 2 +- .../Core/modules/commands_dashboard.js | 2 +- ...eleaderboard.js => economy-leaderboard.js} | 4 +- src/commands/Fun/{flip.js => coin-flip.js} | 4 +- src/commands/Fun/{count.js => counting.js} | 2 +- src/commands/Fun/{roll.js => dice-roll.js} | 4 +- .../{gcreate.js => giveaway-create.js} | 2 +- .../{gdelete.js => giveaway-delete.js} | 2 +- .../Giveaway/{gend.js => giveaway-end.js} | 2 +- .../{greroll.js => giveaway-reroll.js} | 4 +- .../{jointocreate.js => join-to-create.js} | 6 +- .../Leveling/{leveladd.js => level-add.js} | 2 +- .../{leaderboard.js => level-leaderboard.js} | 2 +- .../{levelremove.js => level-remove.js} | 2 +- .../Leveling/{levelset.js => level-set.js} | 2 +- .../Moderation/{massban.js => mass-ban.js} | 4 +- .../Moderation/{masskick.js => mass-kick.js} | 4 +- .../{usernotes.js => user-notes.js} | 2 +- .../Music/{nowplaying.js => now-playing.js} | 2 +- .../{reactroles.js => reaction-roles.js} | 12 +- .../ServerStats/modules/serverstats_create.js | 2 +- .../ServerStats/modules/serverstats_delete.js | 2 +- .../ServerStats/modules/serverstats_list.js | 6 +- .../ServerStats/modules/serverstats_update.js | 2 +- .../{serverstats.js => server-stats.js} | 2 +- .../Tools/{baseconvert.js => base-convert.js} | 6 +- .../{embedbuilder.js => embed-builder.js} | 2 +- ...neratepassword.js => generate-password.js} | 4 +- .../Tools/{hexcolor.js => hex-color.js} | 2 +- .../Tools/{randomuser.js => random-user.js} | 4 +- .../Tools/{shorten.js => shorten-url.js} | 4 +- .../Tools/{unixtime.js => unix-time.js} | 2 +- .../Utility/{firstmsg.js => first-message.js} | 4 +- .../Utility/{serverinfo.js => server-info.js} | 4 +- .../Utility/{userinfo.js => user-info.js} | 4 +- .../Utility/{wipedata.js => wipe-data.js} | 2 +- .../{autoverify.js => auto-verify.js} | 0 .../Verification/modules/autoVerify.js | 2 +- .../modules/autoVerifyDashboard.js | 2 +- .../modules/verification_dashboard.js | 2 +- .../Welcome/{autorole.js => auto-role.js} | 8 +- src/config/commands/commandAliases.js | 226 +++++++++++++++--- src/config/commands/commandCategories.js | 2 +- src/config/commands/prefixRestrictions.js | 3 + src/events/interactionCreate.js | 11 +- src/services/joinToCreateService.js | 2 +- src/services/panelHealthService.js | 2 +- 51 files changed, 274 insertions(+), 116 deletions(-) rename src/commands/Community/{app-admin.js => application-admin.js} (99%) rename src/commands/Core/{configWizard.js => config-wizard.js} (99%) rename src/commands/Economy/{eleaderboard.js => economy-leaderboard.js} (97%) rename src/commands/Fun/{flip.js => coin-flip.js} (90%) rename src/commands/Fun/{count.js => counting.js} (99%) rename src/commands/Fun/{roll.js => dice-roll.js} (95%) rename src/commands/Giveaway/{gcreate.js => giveaway-create.js} (99%) rename src/commands/Giveaway/{gdelete.js => giveaway-delete.js} (99%) rename src/commands/Giveaway/{gend.js => giveaway-end.js} (99%) rename src/commands/Giveaway/{greroll.js => giveaway-reroll.js} (98%) rename src/commands/JoinToCreate/{jointocreate.js => join-to-create.js} (99%) rename src/commands/Leveling/{leveladd.js => level-add.js} (99%) rename src/commands/Leveling/{leaderboard.js => level-leaderboard.js} (98%) rename src/commands/Leveling/{levelremove.js => level-remove.js} (99%) rename src/commands/Leveling/{levelset.js => level-set.js} (99%) rename src/commands/Moderation/{massban.js => mass-ban.js} (99%) rename src/commands/Moderation/{masskick.js => mass-kick.js} (99%) rename src/commands/Moderation/{usernotes.js => user-notes.js} (99%) rename src/commands/Music/{nowplaying.js => now-playing.js} (95%) rename src/commands/Reaction_roles/{reactroles.js => reaction-roles.js} (99%) rename src/commands/ServerStats/{serverstats.js => server-stats.js} (99%) rename src/commands/Tools/{baseconvert.js => base-convert.js} (98%) rename src/commands/Tools/{embedbuilder.js => embed-builder.js} (99%) rename src/commands/Tools/{generatepassword.js => generate-password.js} (98%) rename src/commands/Tools/{hexcolor.js => hex-color.js} (99%) rename src/commands/Tools/{randomuser.js => random-user.js} (99%) rename src/commands/Tools/{shorten.js => shorten-url.js} (98%) rename src/commands/Tools/{unixtime.js => unix-time.js} (98%) rename src/commands/Utility/{firstmsg.js => first-message.js} (96%) rename src/commands/Utility/{serverinfo.js => server-info.js} (96%) rename src/commands/Utility/{userinfo.js => user-info.js} (97%) rename src/commands/Utility/{wipedata.js => wipe-data.js} (98%) rename src/commands/Verification/{autoverify.js => auto-verify.js} (100%) rename src/commands/Welcome/{autorole.js => auto-role.js} (98%) diff --git a/README.md b/README.md index 38620cdf3a..3d9e90afd0 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,7 @@ Music uses [Lavalink v4](https://github.com/lavalink-devs/Lavalink) via [Riffy]( ``` Remove or rename `lavalink/nodes.json` so the bot falls back to those env vars. 3. Override nodes inline with `LAVALINK_NODES` (JSON array) or point at another file with `LAVALINK_NODES_FILE`. -4. Use `/play ` from a voice channel, or `/join` to connect without playing. Paste any **YouTube** link (`youtube.com/watch`, `youtu.be`, `music.youtube.com`, Shorts, and playlists all work) and it plays directly. For searches, `/play` defaults to YouTube Music; use the `source` option to search **YouTube**, Spotify, SoundCloud, or Deezer instead (or type a prefix like `ytsearch:` / `ytmsearch:` directly into the query). Prefix shortcuts: `join`, `np`, `leave`, `pause`, `resume`, `skip`, `stop`, `volume <0-100>`, or `music `. Use `/nowplaying` and `/queue` for status; `/music` for loop, shuffle, seek, and other controls. +4. Use `/play ` from a voice channel, or `/join` to connect without playing. Paste any **YouTube** link (`youtube.com/watch`, `youtu.be`, `music.youtube.com`, Shorts, and playlists all work) and it plays directly. For searches, `/play` defaults to YouTube Music; use the `source` option to search **YouTube**, Spotify, SoundCloud, or Deezer instead (or type a prefix like `ytsearch:` / `ytmsearch:` directly into the query). Prefix shortcuts: `join`, `np`, `leave`, `pause`, `resume`, `skip`, `stop`, `volume <0-100>`, or `music `. Use `/now-playing` and `/queue` for status; `/music` for loop, shuffle, seek, and other controls. ### Using GitHub Container Registry diff --git a/src/commands/Community/app-admin.js b/src/commands/Community/application-admin.js similarity index 99% rename from src/commands/Community/app-admin.js rename to src/commands/Community/application-admin.js index 6ffc02b3f8..89342d5001 100644 --- a/src/commands/Community/app-admin.js +++ b/src/commands/Community/application-admin.js @@ -37,7 +37,7 @@ function getApplicationStatusPresentation(statusValue) { export default { data: new SlashCommandBuilder() - .setName("app-admin") + .setName("application-admin") .setDescription("Manage staff applications") .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) .addSubcommand((subcommand) => @@ -131,7 +131,7 @@ export default { const selectedAppName = interaction.options.getString("application"); await appDashboard.execute(interaction, null, interaction.client, selectedAppName); } - }, { type: 'command', commandName: 'app-admin' }) + }, { type: 'command', commandName: 'application-admin' }) }; async function handleSetup(interaction) { @@ -545,7 +545,7 @@ async function handleList(interaction) { return await replyUserError(interaction, { type: ErrorTypes.CONFIGURATION, message: 'No applications found and no application roles configured.\n' + - 'Use `/app-admin roles add` to configure application roles first.' + 'Use `/application-admin setup` to configure application roles first.' }); } } diff --git a/src/commands/Community/modules/app_dashboard.js b/src/commands/Community/modules/app_dashboard.js index 856a28b918..ad29977346 100644 --- a/src/commands/Community/modules/app_dashboard.js +++ b/src/commands/Community/modules/app_dashboard.js @@ -162,7 +162,7 @@ export default { throw new TitanBotError( 'Applications system not set up', ErrorTypes.CONFIGURATION, - 'The applications system has not been configured yet. Please run `/app-admin setup` to create your first application.', + 'The applications system has not been configured yet. Please run `/application-admin setup` to create your first application.', ); } diff --git a/src/commands/Core/configWizard.js b/src/commands/Core/config-wizard.js similarity index 99% rename from src/commands/Core/configWizard.js rename to src/commands/Core/config-wizard.js index 22ac013ec0..de5a82a8a7 100644 --- a/src/commands/Core/configWizard.js +++ b/src/commands/Core/config-wizard.js @@ -605,7 +605,7 @@ async function handleSettingModalSubmit(selectInteraction, rootInteraction, sett export default { slashOnly: true, data: new SlashCommandBuilder() - .setName('configwizard') + .setName('config-wizard') .setDescription('Open the server configuration dashboard and setup wizard') .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) .setDMPermission(false), diff --git a/src/commands/Core/help.js b/src/commands/Core/help.js index 3d9d7eb783..d32a6bb0ae 100644 --- a/src/commands/Core/help.js +++ b/src/commands/Core/help.js @@ -85,7 +85,7 @@ export async function createInitialHelpMenu(client) { { name: 'πŸš€ Getting Started', value: [ - '**1. Launch setup** β€” Run `/configwizard` to configure prefix, mod role, and logs.', + '**1. Launch setup** β€” Run `/config-wizard` to configure prefix, mod role, and logs.', '**2. Enable systems** β€” Use `/commands dashboard` to turn categories on or off.', '**3. Browse commands** β€” Use the menu below to view categories and commands.', ].join('\n'), inline: false, diff --git a/src/commands/Core/modules/commands_dashboard.js b/src/commands/Core/modules/commands_dashboard.js index 88a8865a50..72bf19a2eb 100644 --- a/src/commands/Core/modules/commands_dashboard.js +++ b/src/commands/Core/modules/commands_dashboard.js @@ -123,7 +123,7 @@ export function buildOverviewEmbed(snapshot, guild) { description: `Manage slash and prefix commands for **${guild.name}**. Subcommands (e.g. \`birthday list\`) are listed separately.`, color: 'info', fields, - footer: 'πŸ”’ commands & configwizard always stay available', + footer: 'πŸ”’ commands & config-wizard always stay available', }); } diff --git a/src/commands/Economy/eleaderboard.js b/src/commands/Economy/economy-leaderboard.js similarity index 97% rename from src/commands/Economy/eleaderboard.js rename to src/commands/Economy/economy-leaderboard.js index e4603797a7..18cdc775b2 100644 --- a/src/commands/Economy/eleaderboard.js +++ b/src/commands/Economy/economy-leaderboard.js @@ -7,7 +7,7 @@ import { getEconomyPrefix } from '../../utils/database.js'; export default { data: new SlashCommandBuilder() - .setName("eleaderboard") + .setName("economy-leaderboard") .setDescription("View the server's top 10 richest users.") .setDMPermission(false), @@ -85,5 +85,5 @@ export default { }); await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - }, { command: 'eleaderboard' }) + }, { command: 'economy-leaderboard' }) }; \ No newline at end of file diff --git a/src/commands/Fun/flip.js b/src/commands/Fun/coin-flip.js similarity index 90% rename from src/commands/Fun/flip.js rename to src/commands/Fun/coin-flip.js index 2eeef5cfa9..23b7c16b08 100644 --- a/src/commands/Fun/flip.js +++ b/src/commands/Fun/coin-flip.js @@ -6,8 +6,8 @@ import { 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)."), + .setName("coin-flip") + .setDescription("Flips a coin (Heads or Tails)."), category: 'Fun', async execute(interaction, config, client) { diff --git a/src/commands/Fun/count.js b/src/commands/Fun/counting.js similarity index 99% rename from src/commands/Fun/count.js rename to src/commands/Fun/counting.js index 1dbd6014b1..f523cf594a 100644 --- a/src/commands/Fun/count.js +++ b/src/commands/Fun/counting.js @@ -16,7 +16,7 @@ import { logger } from '../../utils/logger.js'; import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; export default { data: new SlashCommandBuilder() - .setName('count') + .setName('counting') .setDescription('Manage the server counting game') .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) .setDMPermission(false) diff --git a/src/commands/Fun/roll.js b/src/commands/Fun/dice-roll.js similarity index 95% rename from src/commands/Fun/roll.js rename to src/commands/Fun/dice-roll.js index 988fe1b663..4daabc8510 100644 --- a/src/commands/Fun/roll.js +++ b/src/commands/Fun/dice-roll.js @@ -6,8 +6,8 @@ import { 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).") + .setName("dice-roll") + .setDescription("Rolls dice using standard notation (e.g., 2d20, 1d6 + 5).") .addStringOption((option) => option .setName("notation") diff --git a/src/commands/Giveaway/gcreate.js b/src/commands/Giveaway/giveaway-create.js similarity index 99% rename from src/commands/Giveaway/gcreate.js rename to src/commands/Giveaway/giveaway-create.js index e5f19c048c..71f7652a81 100644 --- a/src/commands/Giveaway/gcreate.js +++ b/src/commands/Giveaway/giveaway-create.js @@ -20,7 +20,7 @@ const GIVEAWAY_MAX_WINNERS = botConfig.giveaways?.maximumWinners ?? 10; export default { data: new SlashCommandBuilder() - .setName("gcreate") + .setName("giveaway-create") .setDescription("Starts a new giveaway in a specified channel.") .addStringOption((option) => option diff --git a/src/commands/Giveaway/gdelete.js b/src/commands/Giveaway/giveaway-delete.js similarity index 99% rename from src/commands/Giveaway/gdelete.js rename to src/commands/Giveaway/giveaway-delete.js index 838209c0c0..e53e75c9fd 100644 --- a/src/commands/Giveaway/gdelete.js +++ b/src/commands/Giveaway/giveaway-delete.js @@ -8,7 +8,7 @@ import { logEvent, EVENT_TYPES } from '../../services/loggingService.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() - .setName("gdelete") + .setName("giveaway-delete") .setDescription( "Deletes a giveaway message and removes it from the database.", ) diff --git a/src/commands/Giveaway/gend.js b/src/commands/Giveaway/giveaway-end.js similarity index 99% rename from src/commands/Giveaway/gend.js rename to src/commands/Giveaway/giveaway-end.js index 700ca5eaeb..2c7d81e0a3 100644 --- a/src/commands/Giveaway/gend.js +++ b/src/commands/Giveaway/giveaway-end.js @@ -13,7 +13,7 @@ import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() - .setName("gend") + .setName("giveaway-end") .setDescription( "Ends an active giveaway immediately and picks the winner(s).", ) diff --git a/src/commands/Giveaway/greroll.js b/src/commands/Giveaway/giveaway-reroll.js similarity index 98% rename from src/commands/Giveaway/greroll.js rename to src/commands/Giveaway/giveaway-reroll.js index 64be4b3e03..abe9c5c97a 100644 --- a/src/commands/Giveaway/greroll.js +++ b/src/commands/Giveaway/giveaway-reroll.js @@ -13,7 +13,7 @@ import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() - .setName("greroll") + .setName("giveaway-reroll") .setDescription("Rerolls the winner(s) for an ended giveaway.") .addStringOption((option) => option @@ -75,7 +75,7 @@ export default { throw new TitanBotError( `Giveaway still active: ${messageId}`, ErrorTypes.VALIDATION, - "This giveaway is still active. Please use `/gend` to end it first.", + "This giveaway is still active. Please use `/giveaway-end` to end it first.", { messageId, status: 'active' } ); } diff --git a/src/commands/JoinToCreate/jointocreate.js b/src/commands/JoinToCreate/join-to-create.js similarity index 99% rename from src/commands/JoinToCreate/jointocreate.js rename to src/commands/JoinToCreate/join-to-create.js index 593212f856..b2b7918903 100644 --- a/src/commands/JoinToCreate/jointocreate.js +++ b/src/commands/JoinToCreate/join-to-create.js @@ -16,7 +16,7 @@ import { export default { data: new SlashCommandBuilder() - .setName("jointocreate") + .setName("join-to-create") .setDescription("Manage Join to Create voice channels system.") .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) .setDMPermission(false) @@ -150,7 +150,7 @@ async function handleSetupSubcommand(interaction, client) { if (activeTriggerChannels.length > 0) { const primaryTrigger = activeTriggerChannels[0]; - const errorMessage = `This server already has a Join to Create channel set up: ${primaryTrigger}\n\nUse \`/jointocreate dashboard\` to modify it, or remove it first before creating a new one.`; + const errorMessage = `This server already has a Join to Create channel set up: ${primaryTrigger}\n\nUse \`/join-to-create dashboard\` to modify it, or remove it first before creating a new one.`; throw new TitanBotError( 'Guild already has a Join to Create channel', @@ -289,7 +289,7 @@ async function handleConfigSubcommand(interaction, client) { throw new TitanBotError( 'Failed to fetch interaction reply for collector setup', ErrorTypes.DISCORD_API, - 'Failed to open configuration controls. Please run `/jointocreate dashboard` again.' + 'Failed to open configuration controls. Please run `/join-to-create dashboard` again.' ); } diff --git a/src/commands/Leveling/leveladd.js b/src/commands/Leveling/level-add.js similarity index 99% rename from src/commands/Leveling/leveladd.js rename to src/commands/Leveling/level-add.js index 9d439696b7..179ec5903b 100644 --- a/src/commands/Leveling/leveladd.js +++ b/src/commands/Leveling/level-add.js @@ -8,7 +8,7 @@ import { createEmbed } from '../../utils/embeds.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() - .setName('leveladd') + .setName('level-add') .setDescription('Add levels to a user') .addUserOption((option) => option diff --git a/src/commands/Leveling/leaderboard.js b/src/commands/Leveling/level-leaderboard.js similarity index 98% rename from src/commands/Leveling/leaderboard.js rename to src/commands/Leveling/level-leaderboard.js index 52169ae6cb..590c3b98d7 100644 --- a/src/commands/Leveling/leaderboard.js +++ b/src/commands/Leveling/level-leaderboard.js @@ -6,7 +6,7 @@ import { getLeaderboard, getLevelingConfig, getXpForLevel } from '../../services import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() - .setName('leaderboard') + .setName('level-leaderboard') .setDescription("Shows the server's level leaderboard") .setDMPermission(false), category: 'Leveling', diff --git a/src/commands/Leveling/levelremove.js b/src/commands/Leveling/level-remove.js similarity index 99% rename from src/commands/Leveling/levelremove.js rename to src/commands/Leveling/level-remove.js index f781d33df8..940d68fe83 100644 --- a/src/commands/Leveling/levelremove.js +++ b/src/commands/Leveling/level-remove.js @@ -8,7 +8,7 @@ import { createEmbed } from '../../utils/embeds.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() - .setName('levelremove') + .setName('level-remove') .setDescription('Remove levels from a user') .addUserOption((option) => option diff --git a/src/commands/Leveling/levelset.js b/src/commands/Leveling/level-set.js similarity index 99% rename from src/commands/Leveling/levelset.js rename to src/commands/Leveling/level-set.js index 4ce8589ac0..48bd4cc5ab 100644 --- a/src/commands/Leveling/levelset.js +++ b/src/commands/Leveling/level-set.js @@ -8,7 +8,7 @@ import { createEmbed } from '../../utils/embeds.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() - .setName('levelset') + .setName('level-set') .setDescription("Set a user's level to a specific value") .addUserOption((option) => option diff --git a/src/commands/Moderation/massban.js b/src/commands/Moderation/mass-ban.js similarity index 99% rename from src/commands/Moderation/massban.js rename to src/commands/Moderation/mass-ban.js index 9f6f8637a4..23c6e2a769 100644 --- a/src/commands/Moderation/massban.js +++ b/src/commands/Moderation/mass-ban.js @@ -8,7 +8,7 @@ import { TitanBotError, replyUserError, ErrorTypes } from '../../utils/errorHand import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() - .setName("massban") + .setName("mass-ban") .setDescription("Ban multiple users from the server at once") .addStringOption(option => option @@ -39,7 +39,7 @@ export default { logger.warn(`Massban interaction defer failed`, { userId: interaction.user.id, guildId: interaction.guildId, - commandName: 'massban' + commandName: 'mass-ban' }); return; } diff --git a/src/commands/Moderation/masskick.js b/src/commands/Moderation/mass-kick.js similarity index 99% rename from src/commands/Moderation/masskick.js rename to src/commands/Moderation/mass-kick.js index 26e7e21a48..033d8113f7 100644 --- a/src/commands/Moderation/masskick.js +++ b/src/commands/Moderation/mass-kick.js @@ -8,7 +8,7 @@ import { TitanBotError, replyUserError, ErrorTypes } from '../../utils/errorHand import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() - .setName("masskick") + .setName("mass-kick") .setDescription("Kick multiple users from the server at once") .addStringOption(option => option @@ -31,7 +31,7 @@ export default { logger.warn(`Masskick interaction defer failed`, { userId: interaction.user.id, guildId: interaction.guildId, - commandName: 'masskick' + commandName: 'mass-kick' }); return; } diff --git a/src/commands/Moderation/usernotes.js b/src/commands/Moderation/user-notes.js similarity index 99% rename from src/commands/Moderation/usernotes.js rename to src/commands/Moderation/user-notes.js index 4fc581fec7..cb87d9f4b5 100644 --- a/src/commands/Moderation/usernotes.js +++ b/src/commands/Moderation/user-notes.js @@ -9,7 +9,7 @@ import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; export default { data: new SlashCommandBuilder() - .setName("usernotes") + .setName("user-notes") .setDescription("Manage user notes for moderation purposes") .addSubcommand(subcommand => subcommand diff --git a/src/commands/Music/nowplaying.js b/src/commands/Music/now-playing.js similarity index 95% rename from src/commands/Music/nowplaying.js rename to src/commands/Music/now-playing.js index e50c685122..157c378546 100644 --- a/src/commands/Music/nowplaying.js +++ b/src/commands/Music/now-playing.js @@ -6,7 +6,7 @@ import { deferMusicCommand } from '../../services/music/prefixSupport.js'; export default { category: 'Music', data: new SlashCommandBuilder() - .setName('nowplaying') + .setName('now-playing') .setDescription('Show the currently playing track'), async execute(interaction, config, client) { diff --git a/src/commands/Reaction_roles/reactroles.js b/src/commands/Reaction_roles/reaction-roles.js similarity index 99% rename from src/commands/Reaction_roles/reactroles.js rename to src/commands/Reaction_roles/reaction-roles.js index 692443c5e9..3bfe370420 100644 --- a/src/commands/Reaction_roles/reactroles.js +++ b/src/commands/Reaction_roles/reaction-roles.js @@ -24,7 +24,7 @@ function truncateText(value, maxLength) { export default { data: new SlashCommandBuilder() - .setName('reactroles') + .setName('reaction-roles') .setDescription('Manage reaction role assignments') .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) .addSubcommand(subcommand => @@ -575,7 +575,7 @@ async function handleDashboard(interaction, selectedPanelId) { throw createError( 'No panels', ErrorTypes.CONFIGURATION, - 'No reaction role panels found. Use `/reactroles setup` first.', + 'No reaction role panels found. Use `/reaction-roles setup` first.', ); } @@ -934,7 +934,7 @@ async function handleRemoveRole(selectInteraction, rootInteraction, panelData, p embeds: [ new EmbedBuilder() .setTitle('Reaction Roles Dashboard') - .setDescription('No panels remain. Use `/reactroles setup` to create one.') + .setDescription('No panels remain. Use `/reaction-roles setup` to create one.') .setColor(getColor('info')), ], components: [], @@ -946,7 +946,7 @@ async function handleRemoveRole(selectInteraction, rootInteraction, panelData, p embeds: [ new EmbedBuilder() .setTitle('Reaction Roles Dashboard') - .setDescription('Panel deleted. Run `/reactroles dashboard` to manage another panel.') + .setDescription('Panel deleted. Run `/reaction-roles dashboard` to manage another panel.') .setColor(getColor('success')), ], components: [], @@ -1075,7 +1075,7 @@ async function handleDeletePanel(btnInteraction, rootInteraction, panelData, pan embeds: [ new EmbedBuilder() .setTitle('Reaction Roles Dashboard') - .setDescription('No panels remain. Use `/reactroles setup` to create one.') + .setDescription('No panels remain. Use `/reaction-roles setup` to create one.') .setColor(getColor('info')), ], components: [], @@ -1086,7 +1086,7 @@ async function handleDeletePanel(btnInteraction, rootInteraction, panelData, pan embeds: [ new EmbedBuilder() .setTitle('Reaction Roles Dashboard') - .setDescription('Panel deleted. Run `/reactroles dashboard` to manage another panel.') + .setDescription('Panel deleted. Run `/reaction-roles dashboard` to manage another panel.') .setColor(getColor('success')), ], components: [], diff --git a/src/commands/ServerStats/modules/serverstats_create.js b/src/commands/ServerStats/modules/serverstats_create.js index e5e750a933..f9f2f65b59 100644 --- a/src/commands/ServerStats/modules/serverstats_create.js +++ b/src/commands/ServerStats/modules/serverstats_create.js @@ -80,7 +80,7 @@ export async function handleCreate(interaction, client) { } await InteractionHelper.safeEditReply(interaction, { - embeds: [successEmbed(`**Counter Created Successfully!**\n\n**Type:** ${getCounterTypeLabel(type)}\n**Channel Type:** ${targetChannel.type === ChannelType.GuildVoice ? 'voice' : 'text'}\n**Category:** ${category}\n**Channel:** ${targetChannel}\n**Channel Name:** ${targetChannel.name}\n**Counter ID:** \`${newCounter.id}\`\n\nThe counter will automatically update every 15 minutes.\n\nUse \`/serverstats list\` to view all counters.`)] + embeds: [successEmbed(`**Counter Created Successfully!**\n\n**Type:** ${getCounterTypeLabel(type)}\n**Channel Type:** ${targetChannel.type === ChannelType.GuildVoice ? 'voice' : 'text'}\n**Category:** ${category}\n**Channel:** ${targetChannel}\n**Channel Name:** ${targetChannel.name}\n**Counter ID:** \`${newCounter.id}\`\n\nThe counter will automatically update every 15 minutes.\n\nUse \`/server-stats list\` to view all counters.`)] }).catch(logger.error); } catch (error) { diff --git a/src/commands/ServerStats/modules/serverstats_delete.js b/src/commands/ServerStats/modules/serverstats_delete.js index 54e3e9ccfc..15694a572f 100644 --- a/src/commands/ServerStats/modules/serverstats_delete.js +++ b/src/commands/ServerStats/modules/serverstats_delete.js @@ -32,7 +32,7 @@ export async function handleDelete(interaction, client) { const counterToDelete = counters.find(c => c.id === counterId); if (!counterToDelete) { - await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: `Counter with ID \`${counterId}\` not found. Use \`/serverstats list\` to see all counters.` }).catch(logger.error); + await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: `Counter with ID \`${counterId}\` not found. Use \`/server-stats list\` to see all counters.` }).catch(logger.error); return; } diff --git a/src/commands/ServerStats/modules/serverstats_list.js b/src/commands/ServerStats/modules/serverstats_list.js index ad6d789227..d627d57b7e 100644 --- a/src/commands/ServerStats/modules/serverstats_list.js +++ b/src/commands/ServerStats/modules/serverstats_list.js @@ -46,7 +46,7 @@ export async function handleList(interaction, client) { if (validCounters.length === 0) { const embed = createEmbed({ title: "Server Counters", - description: "No counters have been set up for this server yet.\n\nUse `/serverstats create` to set up your first counter!", + description: "No counters have been set up for this server yet.\n\nUse `/server-stats create` to set up your first counter!", color: getColor('warning') }); @@ -58,7 +58,7 @@ export async function handleList(interaction, client) { embed.addFields({ name: "**Usage Examples**", - value: "`/serverstats create type:members channel_type:voice category:Stats`\n`/serverstats create type:bots channel_type:text category:Server Info`\n`/serverstats list`", + value: "`/server-stats create type:members channel_type:voice category:Stats`\n`/server-stats create type:bots channel_type:text category:Server Info`\n`/server-stats list`", inline: false }); @@ -107,7 +107,7 @@ export async function handleList(interaction, client) { embed.addFields({ name: "**Management Commands**", - value: "`/serverstats create` - Create new counter\n`/serverstats update` - Update existing counter\n`/serverstats delete` - Delete counter", + value: "`/server-stats create` - Create new counter\n`/server-stats update` - Update existing counter\n`/server-stats delete` - Delete counter", inline: false }); diff --git a/src/commands/ServerStats/modules/serverstats_update.js b/src/commands/ServerStats/modules/serverstats_update.js index b2ebb45434..3092373efe 100644 --- a/src/commands/ServerStats/modules/serverstats_update.js +++ b/src/commands/ServerStats/modules/serverstats_update.js @@ -32,7 +32,7 @@ export async function handleUpdate(interaction, client) { const counterIndex = counters.findIndex(c => c.id === counterId); if (counterIndex === -1) { - await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: `Counter with ID \`${counterId}\` not found. Use \`/serverstats list\` to see all counters.` }).catch(logger.error); + await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: `Counter with ID \`${counterId}\` not found. Use \`/server-stats list\` to see all counters.` }).catch(logger.error); return; } diff --git a/src/commands/ServerStats/serverstats.js b/src/commands/ServerStats/server-stats.js similarity index 99% rename from src/commands/ServerStats/serverstats.js rename to src/commands/ServerStats/server-stats.js index 9fe3dda3b8..6cca1a1706 100644 --- a/src/commands/ServerStats/serverstats.js +++ b/src/commands/ServerStats/server-stats.js @@ -12,7 +12,7 @@ import { InteractionHelper } from '../../utils/interactionHelper.js'; import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; export default { data: new SlashCommandBuilder() - .setName("serverstats") + .setName("server-stats") .setDescription("Manage server statistics that track member counts and channel data") .setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels) .addSubcommand(subcommand => diff --git a/src/commands/Tools/baseconvert.js b/src/commands/Tools/base-convert.js similarity index 98% rename from src/commands/Tools/baseconvert.js rename to src/commands/Tools/base-convert.js index 1ef560dc7e..3d0080df65 100644 --- a/src/commands/Tools/baseconvert.js +++ b/src/commands/Tools/base-convert.js @@ -96,7 +96,7 @@ function formatBigIntToBase(value, baseKey) { export default { data: new SlashCommandBuilder() - .setName('baseconvert') + .setName('base-convert') .setDescription('Convert numbers between different bases') .addStringOption(option => option.setName('number') @@ -119,7 +119,7 @@ export default { logger.warn(`BaseConvert interaction defer failed`, { userId: interaction.user.id, guildId: interaction.guildId, - commandName: 'baseconvert' + commandName: 'base-convert' }); return; } @@ -137,7 +137,7 @@ export default { if (!cleanNumber) { return replyUserError(interaction, { type: ErrorTypes.VALIDATION, - message: 'You must provide a number to convert.\n\n**Example:** `/baseconvert number:1010 from:BIN to:DEC`', + message: 'You must provide a number to convert.\n\n**Example:** `/base-convert number:1010 from:BIN to:DEC`', }); } diff --git a/src/commands/Tools/embedbuilder.js b/src/commands/Tools/embed-builder.js similarity index 99% rename from src/commands/Tools/embedbuilder.js rename to src/commands/Tools/embed-builder.js index 3da94f3dc1..40f5161783 100644 --- a/src/commands/Tools/embedbuilder.js +++ b/src/commands/Tools/embed-builder.js @@ -1041,7 +1041,7 @@ async function handleJsonExport(selectInteraction, rootInteraction, state) { export default { slashOnly: true, data: new SlashCommandBuilder() - .setName('embedbuilder') + .setName('embed-builder') .setDescription('Build and post a fully custom embed with live preview') .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages), diff --git a/src/commands/Tools/generatepassword.js b/src/commands/Tools/generate-password.js similarity index 98% rename from src/commands/Tools/generatepassword.js rename to src/commands/Tools/generate-password.js index 51628bfed0..40e6c4f5f2 100644 --- a/src/commands/Tools/generatepassword.js +++ b/src/commands/Tools/generate-password.js @@ -7,7 +7,7 @@ import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() - .setName('generatepassword') + .setName('generate-password') .setDescription('Generate a strong, random password') .addIntegerOption(option => option.setName('length') @@ -37,7 +37,7 @@ export default { logger.warn('GeneratePassword interaction defer failed', { userId: interaction.user?.id, guildId: interaction.guildId, - commandName: 'generatepassword' + commandName: 'generate-password' }); return; } diff --git a/src/commands/Tools/hexcolor.js b/src/commands/Tools/hex-color.js similarity index 99% rename from src/commands/Tools/hexcolor.js rename to src/commands/Tools/hex-color.js index 36736e3a6e..17054cefbe 100644 --- a/src/commands/Tools/hexcolor.js +++ b/src/commands/Tools/hex-color.js @@ -7,7 +7,7 @@ import { InteractionHelper } from '../../utils/interactionHelper.js'; import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; export default { data: new SlashCommandBuilder() - .setName('hexcolor') + .setName('hex-color') .setDescription('Generate a random hex color with preview') .addStringOption(option => option.setName('color') diff --git a/src/commands/Tools/randomuser.js b/src/commands/Tools/random-user.js similarity index 99% rename from src/commands/Tools/randomuser.js rename to src/commands/Tools/random-user.js index 4dc15a6fd1..1d30a13af1 100644 --- a/src/commands/Tools/randomuser.js +++ b/src/commands/Tools/random-user.js @@ -7,7 +7,7 @@ import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() - .setName('randomuser') + .setName('random-user') .setDescription('Select a random user from the server') .addRoleOption(option => option.setName('role') @@ -32,7 +32,7 @@ export default { logger.warn(`RandomUser interaction defer failed`, { userId: interaction.user.id, guildId: interaction.guildId, - commandName: 'randomuser' + commandName: 'random-user' }); return; } diff --git a/src/commands/Tools/shorten.js b/src/commands/Tools/shorten-url.js similarity index 98% rename from src/commands/Tools/shorten.js rename to src/commands/Tools/shorten-url.js index 4b5b9af5f5..2867c6182e 100644 --- a/src/commands/Tools/shorten.js +++ b/src/commands/Tools/shorten-url.js @@ -7,7 +7,7 @@ import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() - .setName("shorten") + .setName("shorten-url") .setDescription("Shorten a URL using is.gd") .addStringOption(option => option @@ -32,7 +32,7 @@ export default { logger.warn(`Shorten interaction defer failed`, { userId: interaction.user.id, guildId: interaction.guildId, - commandName: 'shorten' + commandName: 'shorten-url' }); return; } diff --git a/src/commands/Tools/unixtime.js b/src/commands/Tools/unix-time.js similarity index 98% rename from src/commands/Tools/unixtime.js rename to src/commands/Tools/unix-time.js index 7e8e7e7b91..c6624e3025 100644 --- a/src/commands/Tools/unixtime.js +++ b/src/commands/Tools/unix-time.js @@ -5,7 +5,7 @@ import { getColor } from '../../config/bot.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() - .setName('unixtime') + .setName('unix-time') .setDescription('Get the current Unix timestamp'), async execute(interaction) { diff --git a/src/commands/Utility/firstmsg.js b/src/commands/Utility/first-message.js similarity index 96% rename from src/commands/Utility/firstmsg.js rename to src/commands/Utility/first-message.js index 1624921387..a9f79342c1 100644 --- a/src/commands/Utility/firstmsg.js +++ b/src/commands/Utility/first-message.js @@ -4,7 +4,7 @@ import { InteractionHelper } from '../../utils/interactionHelper.js'; import { logger } from '../../utils/logger.js'; export default { data: new SlashCommandBuilder() - .setName("firstmsg") + .setName("first-message") .setDescription("Get a link to the first message in this channel") .setDMPermission(false) .setDefaultMemberPermissions(PermissionFlagsBits.SendMessages), @@ -16,7 +16,7 @@ export default { logger.warn(`FirstMsg interaction defer failed`, { userId: interaction.user.id, guildId: interaction.guildId, - commandName: 'firstmsg' + commandName: 'first-message' }); return; } diff --git a/src/commands/Utility/serverinfo.js b/src/commands/Utility/server-info.js similarity index 96% rename from src/commands/Utility/serverinfo.js rename to src/commands/Utility/server-info.js index fd417d6845..168d5f1320 100644 --- a/src/commands/Utility/serverinfo.js +++ b/src/commands/Utility/server-info.js @@ -5,7 +5,7 @@ import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() - .setName("serverinfo") + .setName("server-info") .setDescription("Get detailed information about the server"), async execute(interaction) { @@ -14,7 +14,7 @@ export default { logger.warn(`ServerInfo interaction defer failed`, { userId: interaction.user.id, guildId: interaction.guildId, - commandName: 'serverinfo' + commandName: 'server-info' }); return; } diff --git a/src/commands/Utility/userinfo.js b/src/commands/Utility/user-info.js similarity index 97% rename from src/commands/Utility/userinfo.js rename to src/commands/Utility/user-info.js index 2455b0f4a9..bbb353b67c 100644 --- a/src/commands/Utility/userinfo.js +++ b/src/commands/Utility/user-info.js @@ -4,7 +4,7 @@ import { logger } from '../../utils/logger.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() - .setName("userinfo") + .setName("user-info") .setDescription("Get detailed information about a user") .addUserOption((option) => option @@ -18,7 +18,7 @@ export default { logger.warn(`UserInfo interaction defer failed`, { userId: interaction.user.id, guildId: interaction.guildId, - commandName: 'userinfo' + commandName: 'user-info' }); return; } diff --git a/src/commands/Utility/wipedata.js b/src/commands/Utility/wipe-data.js similarity index 98% rename from src/commands/Utility/wipedata.js rename to src/commands/Utility/wipe-data.js index 941265dca8..88053873ea 100644 --- a/src/commands/Utility/wipedata.js +++ b/src/commands/Utility/wipe-data.js @@ -7,7 +7,7 @@ import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { slashOnly: true, data: new SlashCommandBuilder() - .setName('wipedata') + .setName('wipe-data') .setDescription('Delete all your personal data from the bot (irreversible)'), async execute(interaction, guildConfig, client) { diff --git a/src/commands/Verification/autoverify.js b/src/commands/Verification/auto-verify.js similarity index 100% rename from src/commands/Verification/autoverify.js rename to src/commands/Verification/auto-verify.js diff --git a/src/commands/Verification/modules/autoVerify.js b/src/commands/Verification/modules/autoVerify.js index 31ca0e881d..c1e79a1046 100644 --- a/src/commands/Verification/modules/autoVerify.js +++ b/src/commands/Verification/modules/autoVerify.js @@ -16,7 +16,7 @@ const defaultAccountAgeDays = autoVerifyDefaults.defaultAccountAgeDays ?? 7; export default { data: new SlashCommandBuilder() - .setName("autoverify") + .setName("auto-verify") .setDescription("Configure automatic verification settings") .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) .addSubcommand(subcommand => diff --git a/src/commands/Verification/modules/autoVerifyDashboard.js b/src/commands/Verification/modules/autoVerifyDashboard.js index 7209da79fd..923082107f 100644 --- a/src/commands/Verification/modules/autoVerifyDashboard.js +++ b/src/commands/Verification/modules/autoVerifyDashboard.js @@ -161,7 +161,7 @@ export default { embeds: [ new EmbedBuilder() .setTitle('πŸ€– Auto-Verification Dashboard') - .setDescription(`Auto-verification is not yet configured.${blockingText}\n\nUse \`/autoverify setup\` to configure it.`) + .setDescription(`Auto-verification is not yet configured.${blockingText}\n\nUse \`/auto-verify setup\` to configure it.`) .setColor(getColor('warning')) .setFooter({ text: 'Dashboard closes after 10 minutes of inactivity' }) .setTimestamp() diff --git a/src/commands/Verification/modules/verification_dashboard.js b/src/commands/Verification/modules/verification_dashboard.js index ab32689527..822daf46cd 100644 --- a/src/commands/Verification/modules/verification_dashboard.js +++ b/src/commands/Verification/modules/verification_dashboard.js @@ -330,7 +330,7 @@ export default { if (!wasEnabled && autoVerifyEnabled) { await replyUserError(btnInteraction, { type: ErrorTypes.CONFIGURATION, - message: 'AutoVerify is currently enabled. Please disable AutoVerify first before enabling the manual Verification system.\n\nRun `/autoverify` to access the AutoVerify dashboard.', + message: 'AutoVerify is currently enabled. Please disable AutoVerify first before enabling the manual Verification system.\n\nRun `/auto-verify` to access the AutoVerify dashboard.', }); return; } diff --git a/src/commands/Welcome/autorole.js b/src/commands/Welcome/auto-role.js similarity index 98% rename from src/commands/Welcome/autorole.js rename to src/commands/Welcome/auto-role.js index 81764e4c46..e1e4b7ea65 100644 --- a/src/commands/Welcome/autorole.js +++ b/src/commands/Welcome/auto-role.js @@ -15,7 +15,7 @@ function createAutoroleInfoEmbed(description) { export default { data: new SlashCommandBuilder() - .setName('autorole') + .setName('auto-role') .setDescription('Manage roles that are automatically assigned to new members') .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) .addSubcommand(subcommand => @@ -42,16 +42,16 @@ export default { async execute(interaction) { const deferSuccess = await InteractionHelper.safeDefer(interaction); if (!deferSuccess) { - logger.warn(`Autorole interaction defer failed`, { + logger.warn(`AutoRole interaction defer failed`, { userId: interaction.user.id, guildId: interaction.guildId, - commandName: 'autorole' + commandName: 'auto-role' }); return; } if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) { - return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need the **Manage Server** permission to use `/autorole`.' }); + return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need the **Manage Server** permission to use `/auto-role`.' }); } const { options, guild, client } = interaction; diff --git a/src/config/commands/commandAliases.js b/src/config/commands/commandAliases.js index b23023a143..53b795de63 100644 --- a/src/config/commands/commandAliases.js +++ b/src/config/commands/commandAliases.js @@ -3,13 +3,22 @@ * Maps shortened command names to their full command names */ +/** + * Command Aliases Configuration + * Maps shortened command names to their full command names + */ + export const commandAliases = { + // Economy 'bal': 'balance', 'money': 'balance', 'cash': 'balance', + 'balance': 'balance', 'dep': 'deposit', + 'deposit': 'deposit', 'with': 'withdraw', + 'withdraw': 'withdraw', 'work': 'work', 'daily': 'daily', 'gamble': 'gamble', @@ -19,91 +28,232 @@ export const commandAliases = { 'pay': 'pay', 'give': 'pay', 'send': 'pay', + 'beg': 'beg', + 'fish': 'fish', + 'mine': 'mine', + 'slut': 'slut', + 'economy': 'economy', + 'shop': 'shop', + 'buy': 'buy', + 'inventory': 'inventory', + 'inv': 'inventory', + 'items': 'inventory', + 'shop-config': 'shop-config', + 'shopconfig': 'shop-config', + 'eleaderboard': 'economy-leaderboard', + 'elb': 'economy-leaderboard', + 'rich': 'economy-leaderboard', + 'richest': 'economy-leaderboard', + 'economy-leaderboard': 'economy-leaderboard', + 'economyleaderboard': 'economy-leaderboard', + + // Core 'ping': 'ping', 'help': 'help', 'h': 'help', 'info': 'help', + 'commands': 'commands', + 'cmds': 'commands', + 'configwizard': 'config-wizard', + 'config-wizard': 'config-wizard', + 'config': 'config-wizard', + 'wizard': 'config-wizard', + 'stats': 'stats', + 'botstats': 'stats', + 'support': 'support', + 'uptime': 'uptime', + // Moderation 'ban': 'ban', 'kick': 'kick', 'mute': 'timeout', + 'timeout': 'timeout', 'warn': 'warn', + 'warnings': 'warnings', 'clear': 'purge', 'purge': 'purge', 'untimeout': 'untimeout', 'unmute': 'untimeout', + 'unban': 'unban', + 'lock': 'lock', + 'unlock': 'unlock', + 'say': 'say', + 'dm': 'dm', + 'cases': 'cases', + 'massban': 'mass-ban', + 'mass-ban': 'mass-ban', + 'masskick': 'mass-kick', + 'mass-kick': 'mass-kick', + 'usernotes': 'user-notes', + 'user-notes': 'user-notes', + 'notes': 'user-notes', + // Leveling 'rank': 'rank', 'lvl': 'rank', 'xp': 'rank', - 'leaderboard': 'leaderboard', - 'lb': 'leaderboard', - 'top': 'leaderboard', + 'level': 'level', + 'leaderboard': 'level-leaderboard', + 'level-leaderboard': 'level-leaderboard', + 'levelleaderboard': 'level-leaderboard', + 'lb': 'level-leaderboard', + 'top': 'level-leaderboard', + 'levels': 'level-leaderboard', + 'leveladd': 'level-add', + 'level-add': 'level-add', + 'levelremove': 'level-remove', + 'level-remove': 'level-remove', + 'levelset': 'level-set', + 'level-set': 'level-set', - 'shop': 'shop', - 'buy': 'buy', - 'inventory': 'inventory', - 'inv': 'inventory', - 'items': 'inventory', - - 'user': 'userinfo', + // Utility & User + 'user': 'user-info', + 'userinfo': 'user-info', + 'user-info': 'user-info', + 'whois': 'user-info', + 'ui': 'user-info', 'avatar': 'avatar', 'pfp': 'avatar', 'icon': 'avatar', + 'banner': 'banner', + 'firstmsg': 'first-message', + 'first-message': 'first-message', + 'firstmessage': 'first-message', + 'serverinfo': 'server-info', + 'server-info': 'server-info', + 'si': 'server-info', + 'weather': 'weather', + 'todo': 'todo', + 'report': 'report', + 'wipedata': 'wipe-data', + 'wipe-data': 'wipe-data', + // Birthday + 'birthday': 'birthday', 'bd': 'birthday', 'bday': 'birthday', 'b': 'birthday', - 'flip': 'flip', - 'coin': 'flip', - 'roll': 'roll', - 'dice': 'roll', + // Fun + 'flip': 'coin-flip', + 'coin': 'coin-flip', + 'coinflip': 'coin-flip', + 'coin-flip': 'coin-flip', + 'roll': 'dice-roll', + 'dice': 'dice-roll', + 'diceroll': 'dice-roll', + 'dice-roll': 'dice-roll', 'fight': 'fight', + 'count': 'counting', + 'counting': 'counting', - 'gcreate': 'gcreate', - 'gstart': 'gcreate', - 'gend': 'gend', - 'gstop': 'gend', - 'gdelete': 'gdelete', - 'greroll': 'greroll', - 'groll': 'greroll', + // Giveaway + 'gcreate': 'giveaway-create', + 'gstart': 'giveaway-create', + 'giveaway-create': 'giveaway-create', + 'giveawaycreate': 'giveaway-create', + 'gend': 'giveaway-end', + 'gstop': 'giveaway-end', + 'giveaway-end': 'giveaway-end', + 'giveawayend': 'giveaway-end', + 'gdelete': 'giveaway-delete', + 'giveaway-delete': 'giveaway-delete', + 'giveawaydelete': 'giveaway-delete', + 'greroll': 'giveaway-reroll', + 'groll': 'giveaway-reroll', + 'giveaway-reroll': 'giveaway-reroll', + 'giveawayreroll': 'giveaway-reroll', + // Ticket 'ticket': 'ticket', 't': 'ticket', 'new': 'ticket', + 'claim': 'claim', + 'close': 'close', + 'priority': 'priority', + // Verification + 'verify': 'verify', 'ver': 'verify', + 'verification': 'verification', 'vadmin': 'verification', - 'av': 'autoverify', + 'autoverify': 'auto-verify', + 'auto-verify': 'auto-verify', + 'av': 'auto-verify', + // Welcome 'welcome': 'welcome', 'greet': 'greet', 'goodbye': 'goodbye', - 'autorole': 'autorole', + 'autorole': 'auto-role', + 'auto-role': 'auto-role', + // Tools 'calc': 'calculate', 'math': 'calculate', - 'weather': 'weather', - 'todo': 'todo', - 'report': 'report', - 'userinfo': 'userinfo', - 'whois': 'userinfo', - 'ui': 'userinfo', + 'calculate': 'calculate', + 'countdown': 'countdown', + 'timer': 'countdown', + 'embedbuilder': 'embed-builder', + 'embed-builder': 'embed-builder', + 'embed': 'embed-builder', + 'generatepassword': 'generate-password', + 'generate-password': 'generate-password', + 'genpass': 'generate-password', + 'password': 'generate-password', + 'hexcolor': 'hex-color', + 'hex-color': 'hex-color', + 'color': 'hex-color', + 'poll': 'poll', + 'randomuser': 'random-user', + 'random-user': 'random-user', + 'randuser': 'random-user', + 'shorten': 'shorten-url', + 'shorten-url': 'shorten-url', + 'shorturl': 'shorten-url', + 'time': 'time', + 'unixtime': 'unix-time', + 'unix-time': 'unix-time', + 'timestamp': 'unix-time', + 'baseconvert': 'base-convert', + 'base-convert': 'base-convert', + + // Server Stats + 'serverstats': 'server-stats', + 'server-stats': 'server-stats', + 'ss': 'server-stats', + 'sstats': 'server-stats', + + // Reaction Roles + 'rr': 'reaction-roles', + 'reactionroles': 'reaction-roles', + 'reactroles': 'reaction-roles', + 'reaction-roles': 'reaction-roles', - 'serverstats': 'serverstats', - 'ss': 'serverstats', - 'sstats': 'serverstats', + // Join to Create + 'jtc': 'join-to-create', + 'jointocreate': 'join-to-create', + 'join-to-create': 'join-to-create', - 'rr': 'reactroles', - 'reactionroles': 'reactroles', + // Music + 'nowplaying': 'now-playing', + 'now-playing': 'now-playing', + 'np': 'now-playing', + 'now': 'now-playing', + 'play': 'play', + 'join': 'join', + 'music': 'music', + 'queue': 'queue', - 'jtc': 'jointocreate', - 'jointocreate': 'jointocreate', + // Community + 'app-admin': 'application-admin', + 'application-admin': 'application-admin', + 'apply': 'apply', - 'np': 'nowplaying', - 'now': 'nowplaying', + // Search + 'search': 'search', + 'logging': 'logging', }; export const subcommandAliases = { diff --git a/src/config/commands/commandCategories.js b/src/config/commands/commandCategories.js index 3d3ec1b5ee..003f94faf5 100644 --- a/src/config/commands/commandCategories.js +++ b/src/config/commands/commandCategories.js @@ -25,7 +25,7 @@ export const CATEGORY_ICONS = { }; /** Commands that always stay available so admins can recover access. */ -export const PROTECTED_COMMANDS = new Set(['commands', 'configwizard']); +export const PROTECTED_COMMANDS = new Set(['commands', 'config-wizard', 'configwizard']); export function normalizeCategoryKey(category) { return String(category || '') diff --git a/src/config/commands/prefixRestrictions.js b/src/config/commands/prefixRestrictions.js index 2b57f7bd60..6532103e2a 100644 --- a/src/config/commands/prefixRestrictions.js +++ b/src/config/commands/prefixRestrictions.js @@ -4,9 +4,12 @@ /** Top-level commands that cannot be invoked via prefix at all. */ export const SLASH_ONLY_COMMANDS = new Set([ + 'config-wizard', 'configwizard', 'help', + 'embed-builder', 'embedbuilder', + 'wipe-data', 'wipedata', 'apply', ]); diff --git a/src/events/interactionCreate.js b/src/events/interactionCreate.js index 50a468b73e..5cc3ab8d9c 100644 --- a/src/events/interactionCreate.js +++ b/src/events/interactionCreate.js @@ -30,10 +30,15 @@ const COMMAND_ERROR_SUBTYPES = { warnings: 'warnings_view_failed', ticket: 'ticket_failed', serverstats: 'serverstats_failed', + 'server-stats': 'serverstats_failed', gcreate: 'giveaway_failed', + 'giveaway-create': 'giveaway_failed', gend: 'giveaway_failed', + 'giveaway-end': 'giveaway_failed', gdelete: 'giveaway_failed', + 'giveaway-delete': 'giveaway_failed', greroll: 'giveaway_failed', + 'giveaway-reroll': 'giveaway_failed', }; function withTraceContext(context = {}, traceContext = {}) { @@ -211,7 +216,7 @@ export default { }); await interaction.respond([]); } - } else if (interaction.commandName === 'app-admin' && focusedOption.name === 'application') { + } else if ((interaction.commandName === 'application-admin' || interaction.commandName === 'app-admin') && focusedOption.name === 'application') { try { const { getApplicationRoles } = await import('../utils/database.js'); const roles = await getApplicationRoles(client, interaction.guildId); @@ -228,14 +233,14 @@ export default { })) ); } catch (error) { - logger.error('Error handling app-admin autocomplete:', { + logger.error('Error handling application-admin autocomplete:', { error: error.message, guildId: interaction.guildId, commandName: interaction.commandName }); await interaction.respond([]); } - } else if (interaction.commandName === 'reactroles' && focusedOption.name === 'panel') { + } else if ((interaction.commandName === 'reaction-roles' || interaction.commandName === 'reactroles') && focusedOption.name === 'panel') { try { const { getAllReactionRoleMessages, deleteReactionRoleMessage } = await import('../services/reactionRoleService.js'); const guildId = interaction.guildId; diff --git a/src/services/joinToCreateService.js b/src/services/joinToCreateService.js index a39318716f..cc5581cb72 100644 --- a/src/services/joinToCreateService.js +++ b/src/services/joinToCreateService.js @@ -219,7 +219,7 @@ export async function initializeJoinToCreate(client, guildId, channelId, options throw new TitanBotError( 'Guild already has a Join to Create trigger configured', ErrorTypes.VALIDATION, - 'This server already has a Join to Create channel configured. Use `/jointocreate dashboard` to modify it, or remove it before creating a new one.', + 'This server already has a Join to Create channel configured. Use `/join-to-create dashboard` to modify it, or remove it before creating a new one.', { guildId, existingTriggerChannelId: config.triggerChannels[0], diff --git a/src/services/panelHealthService.js b/src/services/panelHealthService.js index 1a5a32196e..2fc26997fe 100644 --- a/src/services/panelHealthService.js +++ b/src/services/panelHealthService.js @@ -153,7 +153,7 @@ export async function reconcileReactionRolePanelHealth(client) { } else if (panelStatus.reason === 'panel_deleted') { summary.deletedPanels += 1; logger.warn( - `Reaction role panel deleted for guild ${guild.id} β€” repost from /reactroles dashboard`, + `Reaction role panel deleted for guild ${guild.id} β€” repost from /reaction-roles dashboard`, ); } } From 4d4c1dbc0a09a7472a2c3f60c1dfd956f9e493e3 Mon Sep 17 00:00:00 2001 From: Arena Agent Date: Tue, 11 Aug 2026 15:41:14 +0000 Subject: [PATCH 13/13] feat: add /api service status command (GitHub, Railway, bot) Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- src/commands/Core/api.js | 275 +++++++++++++++++++++++ src/interactions/buttons/api.js | 35 +++ src/utils/statusChecker.js | 387 ++++++++++++++++++++++++++++++++ 3 files changed, 697 insertions(+) create mode 100644 src/commands/Core/api.js create mode 100644 src/interactions/buttons/api.js create mode 100644 src/utils/statusChecker.js diff --git a/src/commands/Core/api.js b/src/commands/Core/api.js new file mode 100644 index 0000000000..8abbd9deae --- /dev/null +++ b/src/commands/Core/api.js @@ -0,0 +1,275 @@ +import { + SlashCommandBuilder, + MessageFlags, + ButtonBuilder, + ButtonStyle, + ActionRowBuilder, +} from 'discord.js'; +import { createEmbed, formatDuration } from '../../utils/embeds.js'; +import { logger } from '../../utils/logger.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { checkAllServices } from '../../utils/statusChecker.js'; +import pkg from '../../../package.json' with { type: 'json' }; + +// Button custom id for refresh. Colon-split so the generic dispatcher in +// interactionCreate.js routes this through client.buttons correctly. +const REFRESH_ID = 'api:refresh'; + +// Width used to pad names inside the probes code block so the latencies line up. +const PROBES_NAME_WIDTH = 18; + +function buildRefreshButton(disabled = false) { + const btn = new ButtonBuilder() + .setCustomId(REFRESH_ID) + .setLabel('Refresh Status') + .setStyle(ButtonStyle.Secondary); + // Note: setEmoji is intentionally not used with a Unicode emoji because the + // sanitizer strips them from button labels; the text label is enough. + if (disabled) btn.setDisabled(true); + return new ActionRowBuilder().addComponents(btn); +} + +/** + * Convert an overall status to the headline banner text shown at the top of the + * embed, styled after the reference screenshot ("[+] All systems operational"). + * ASCII markers are used because the embed sanitizer strips Unicode emojis. + */ +function overallHeadline(overall) { + switch (overall.status) { + case 'online': return '**[+] All systems operational**'; + case 'issues': return '**[!] Some services are experiencing issues**'; + case 'offline': return '**[X] One or more services are offline**'; + default: return '**[?] Service status unknown**'; + } +} + +/** + * Pick an ANSI foreground color for a given status, used inside the monospace + * probes block (Discord supports ANSI inside ```ansi fences). + */ +function statusAnsi(status) { + switch (status) { + case 'online': return '\x1b[32m'; // green + case 'issues': return '\x1b[33m'; // yellow + case 'offline': return '\x1b[31m'; // red + default: return '\x1b[90m'; // gray + } +} + +const ANSI_RESET = '\x1b[0m'; + +function statusGlyph(status) { + switch (status) { + case 'online': return '+'; // green "+" (online marker) + case 'issues': return '!'; // yellow "!" (warning) + case 'offline': return 'X'; // red "X" (offline) + default: return '?'; + } +} + +function statusLabelInline(status) { + switch (status) { + case 'online': return 'Online'; + case 'issues': return 'Issues'; + case 'offline': return 'Offline'; + default: return 'Unknown'; + } +} + +function padRight(str, width) { + // str is plain ASCII-ish text, so String.length is good enough. + if (str.length >= width) return str.slice(0, width); + return str + ' '.repeat(width - str.length); +} + +function buildProbesBlock(services) { + const lines = []; + const headerName = padRight('Service', PROBES_NAME_WIDTH); + const header = `${headerName} Latency`; + lines.push(header); + lines.push('─'.repeat(header.length)); + + for (const s of services) { + const color = statusAnsi(s.status); + const glyph = statusGlyph(s.status); + const name = padRight(s.name, PROBES_NAME_WIDTH); + const latency = typeof s.latency === 'number' && s.latency >= 0 ? `${s.latency}ms` : ' --'; + const line = `${color}[${glyph}]${ANSI_RESET} ${name} ${padStart(latency, 8)}`; + lines.push(line); + } + + return '```ansi\n' + lines.join('\n') + '\n```'; +} + +function padStart(str, width) { + if (str.length >= width) return str; + return ' '.repeat(width - str.length) + str; +} + +function buildServicesList(services) { + return services.map((s) => { + const glyph = statusGlyph(s.status); + const label = `\`${statusLabelInline(s.status)}\``; + const latency = typeof s.latency === 'number' && s.latency >= 0 + ? ` β€” \`${s.latency}ms\`` + : ''; + const detail = s.detail ? `\n> ${s.detail}` : ''; + return `**[${glyph}] ${s.name}** β€” ${label}${latency}${detail}`; + }).join('\n'); +} + +/** + * Build the reply payload (embed + components) for a given status result. + * Layout mirrors the reference screenshot: bold "REAL STATUS" title, headline + * banner, Product info section, Services section, and a monospace Probes + * block with per-service latencies. + */ +function buildStatusPayload(result) { + const { services, overall, checkedAt } = result; + const checkedTs = Math.floor(checkedAt.getTime() / 1000); + const bot = services.find(s => s.name === 'Discord Bot') ?? services[2]; + const uptimeMs = bot?.uptimeMs ?? 0; + const wsPing = typeof bot?.latency === 'number' ? bot.latency : null; + + const productLines = [ + `β€’ **Version:** \`${pkg.version}\``, + wsPing != null ? `β€’ **Gateway Ping:** \`${wsPing}ms\`` : `β€’ **Gateway Ping:** \`Unknown\``, + uptimeMs > 0 ? `β€’ **Uptime:** \`${formatDuration(uptimeMs)}\`` : `β€’ **Uptime:** \`Unknown\``, + `β€’ **Node:** \`${process.version}\``, + ].join('\n'); + + const description = [ + overallHeadline(overall), + '', + '**Product**', + productLines, + '', + '**Services**', + buildServicesList(services), + '', + '**Probes**', + buildProbesBlock(services), + '', + `*Last checked β€” use the button below to refresh*`, + ].join('\n'); + + // Create the base embed, then force the sidebar color to match overall status. + const embed = createEmbed({ + title: 'REAL STATUS', + description, + color: 'dark', + timestamp: false, + }); + + try { + embed.setColor(overall.color); + } catch { + // ignore + } + + return { + embeds: [embed], + components: [buildRefreshButton(false)], + }; +} + +async function runStatusCheck(interaction) { + try { + const result = await checkAllServices(interaction.client); + return buildStatusPayload(result); + } catch (error) { + logger.error('Status check failed with unexpected error:', error); + // Fallback payload β€” never crash the interaction + const embed = createEmbed({ + title: 'REAL STATUS', + description: '**[!] Could not complete the status check.** Please try again in a moment.', + color: 'warning', + }); + return { + embeds: [embed], + components: [buildRefreshButton(false)], + }; + } +} + +export default { + data: new SlashCommandBuilder() + .setName('api') + .setDescription('Check the status of the bot, GitHub, and Railway services'), + + async prefixExecute(interaction) { + try { + const thinkingMsg = await interaction.reply({ content: 'Checking service status…' }); + const payload = await runStatusCheck(interaction); + await thinkingMsg.edit({ content: null, ...payload }).catch(() => { + interaction.channel?.send(payload).catch(() => {}); + }); + } catch (error) { + logger.error('API (prefix) command error:', error); + interaction.channel?.send({ + embeds: [createEmbed({ title: 'REAL STATUS', description: 'Could not check service status.', color: 'error' })], + }).catch(() => {}); + } + }, + + async execute(interaction) { + const isButton = interaction.isButton?.() && interaction.customId === REFRESH_ID; + + if (isButton) { + // Button press: defer an update, then re-check and rebuild + try { + await interaction.deferUpdate(); + } catch (deferError) { + logger.warn('API status refresh: deferUpdate failed:', deferError?.message); + return; + } + + try { + const payload = await runStatusCheck(interaction); + await interaction.editReply(payload).catch(err => { + logger.warn('API status refresh: editReply failed:', err?.message); + }); + } catch (error) { + logger.error('API status refresh error:', error); + try { + const embed = createEmbed({ + title: 'REAL STATUS', + description: '**[X] Could not refresh status right now.** Try again shortly.', + color: 'error', + }); + await interaction.editReply({ embeds: [embed], components: [buildRefreshButton(false)] }).catch(() => {}); + } catch { + // last-resort swallow + } + } + return; + } + + // Slash command β€” defer publicly so the status is visible to everyone + const deferSuccess = await InteractionHelper.safeDefer(interaction, {}); + if (!deferSuccess) { + logger.warn(`API command: interaction defer failed`, { + userId: interaction.user.id, + guildId: interaction.guildId, + commandName: 'api', + }); + return; + } + + try { + const payload = await runStatusCheck(interaction); + await InteractionHelper.safeEditReply(interaction, payload); + } catch (error) { + logger.error('API command error:', error); + try { + await InteractionHelper.safeEditReply(interaction, { + embeds: [createEmbed({ title: 'REAL STATUS', description: 'Could not check service status.', color: 'error' })], + components: [], + flags: MessageFlags.Ephemeral, + }); + } catch (replyError) { + logger.error('API command: failed to send error reply:', replyError); + } + } + }, +}; diff --git a/src/interactions/buttons/api.js b/src/interactions/buttons/api.js new file mode 100644 index 0000000000..bd01a7990a --- /dev/null +++ b/src/interactions/buttons/api.js @@ -0,0 +1,35 @@ +import apiCommand from '../../commands/Core/api.js'; + +/** + * Refresh button for the /api status embed. + * Delegates to the command's execute() which detects button presses via + * interaction.customId starting with 'api:' and re-runs the checks, editing + * the original reply in place. + */ +export default { + name: 'api', + async execute(interaction, client) { + try { + await apiCommand.execute(interaction); + } catch (error) { + // Safety net β€” the command already handles its own errors, but catch + // anything unexpected so the interaction never crashes the event loop. + try { + const { createEmbed } = await import('../../utils/embeds.js'); + const { InteractionHelper } = await import('../../utils/interactionHelper.js'); + const embed = createEmbed({ + title: 'System Error', + description: 'Could not refresh service status. Please try again later.', + color: 'error', + }); + if (interaction.deferred || interaction.replied) { + await InteractionHelper.safeEditReply(interaction, { embeds: [embed], components: [] }); + } else { + await InteractionHelper.safeReply(interaction, { embeds: [embed], ephemeral: true }); + } + } catch { + // last-resort swallow + } + } + }, +}; diff --git a/src/utils/statusChecker.js b/src/utils/statusChecker.js new file mode 100644 index 0000000000..a196dc0394 --- /dev/null +++ b/src/utils/statusChecker.js @@ -0,0 +1,387 @@ +import axios from 'axios'; +import { logger } from './logger.js'; + +// Status indicators +const STATUS = Object.freeze({ + ONLINE: 'online', + ISSUES: 'issues', + OFFLINE: 'offline', + UNKNOWN: 'unknown', +}); + +const STATUS_META = Object.freeze({ + [STATUS.ONLINE]: { + label: 'Online', + emoji: '<:online:1277944700025471016>', // fall back to text below if emoji not available + fallbackEmoji: '🟒', + color: '#57F287', + }, + [STATUS.ISSUES]: { + label: 'Having Issues', + emoji: '<:idle:1277944782640496702>', + fallbackEmoji: '🟑', + color: '#FEE75C', + }, + [STATUS.OFFLINE]: { + label: 'Offline', + emoji: '<:dnd:1277944754970677303>', + fallbackEmoji: 'πŸ”΄', + color: '#ED4245', + }, + [STATUS.UNKNOWN]: { + label: 'Unknown', + emoji: '<:offline:1277944802240888862>', + fallbackEmoji: '⚫', + color: '#99AAB5', + }, +}); + +const DEFAULT_TIMEOUT_MS = 6000; + +/** + * Resolve the emoji text to use. Falls back to the colored circle if the custom + * emoji ID is unavailable in the current guild. + */ +function getEmoji(meta) { + return meta?.fallbackEmoji ?? '⚫'; +} + +/** + * Safely perform an HTTP GET, returning { ok, data, responseTimeMs, error } on a + * short timeout. Never throws; all network/promise failures are captured. + */ +async function safeGet(url, { timeout = DEFAULT_TIMEOUT_MS, headers = {} } = {}) { + const start = Date.now(); + try { + const res = await axios.get(url, { + timeout, + headers: { + 'User-Agent': 'SlitzBot-StatusChecker/1.0', + 'Accept': 'application/json,text/plain,*/*', + ...headers, + }, + // Don't let non-2xx codes reject; we want to inspect them ourselves + validateStatus: () => true, + }); + return { + ok: res.status >= 200 && res.status < 400, + status: res.status, + data: res.data, + responseTimeMs: Date.now() - start, + error: null, + }; + } catch (error) { + return { + ok: false, + status: null, + data: null, + responseTimeMs: Date.now() - start, + error, + }; + } +} + +/** + * Interpret an Atlassian Statuspage /status.json payload + * (used by GitHub: https://www.githubstatus.com/api/v2/status.json) + */ +function interpretStatuspageStatus(payload) { + const indicator = payload?.status?.indicator; + const description = payload?.status?.description || ''; + + // indicator can be: 'none', 'minor', 'major', 'critical', 'maintenance' + if (!indicator) { + return { status: STATUS.UNKNOWN, detail: description || 'Status indicator unavailable' }; + } + + switch (String(indicator).toLowerCase()) { + case 'none': + return { status: STATUS.ONLINE, detail: description || 'All systems operational' }; + case 'minor': + case 'maintenance': + return { status: STATUS.ISSUES, detail: description || 'Minor issues or maintenance in progress' }; + case 'major': + case 'critical': + return { status: STATUS.OFFLINE, detail: description || 'Major service outage' }; + default: + return { status: STATUS.UNKNOWN, detail: description || 'Unknown status' }; + } +} + +/** + * Interpret an Instatus summary.json payload (used by Railway). + * The schema looks like: + * { page: {...}, status: { indicator: "OPERATIONAL"|"HASISSUES"|"UNDERMAINTENANCE"|..., description: "..." } } + * Components (v2/components.json) look like: [{ name, status: "OPERATIONAL"|"DEGRADEDPERFORMANCE"|..., ... }] + */ +function interpretInstatusSummary(summaryPayload, componentsPayload) { + // First look at the page-level indicator. + const indicator = typeof summaryPayload?.status?.indicator === 'string' + ? summaryPayload.status.indicator.toUpperCase().replace(/[^A-Z]/g, '') + : null; + const description = summaryPayload?.status?.description || ''; + + // Aggregate component statuses (component names + status for detail building). + const components = Array.isArray(componentsPayload) ? componentsPayload : []; + const notOperational = components + .filter(c => c && typeof c.status === 'string' && c.status.toUpperCase() !== 'OPERATIONAL') + .map(c => ({ name: c.name || 'Unknown component', status: c.status.toUpperCase() })); + + const componentWorst = (() => { + if (notOperational.length === 0) return STATUS.ONLINE; + const critical = ['MAJOROUTAGE', 'PARTIALOUTAGE', 'OUTAGE', 'DOWN', 'CRITICAL']; + const warning = ['DEGRADEDPERFORMANCE', 'MINOROUTAGE', 'HASISSUES', 'UNDERMAINTENANCE', 'MAINTENANCE', 'MINOR']; + if (notOperational.some(c => critical.some(k => c.status.includes(k)))) return STATUS.OFFLINE; + if (notOperational.some(c => warning.some(k => c.status.includes(k)))) return STATUS.ISSUES; + return STATUS.ISSUES; + })(); + + // Page indicator + let pageStatus = STATUS.ONLINE; + if (indicator) { + if (['OPERATIONAL', 'UP', 'NONE'].includes(indicator)) pageStatus = STATUS.ONLINE; + else if (['HASISSUES', 'MINOR', 'UNDERMAINTENANCE', 'MAINTENANCE', 'DEGRADEDPERFORMANCE'].includes(indicator)) pageStatus = STATUS.ISSUES; + else if (['MAJOROUTAGE', 'PARTIALOUTAGE', 'OUTAGE', 'DOWN', 'CRITICAL', 'MAJOR'].includes(indicator)) pageStatus = STATUS.OFFLINE; + else pageStatus = STATUS.UNKNOWN; + } else { + pageStatus = componentWorst; + } + + // Final status = worst of page and components + const rank = { [STATUS.ONLINE]: 0, [STATUS.ISSUES]: 1, [STATUS.OFFLINE]: 2, [STATUS.UNKNOWN]: -1 }; + const finalStatus = [pageStatus, componentWorst].reduce( + (worst, s) => (rank[s] > rank[worst] ? s : worst), + STATUS.ONLINE, + ); + + // Build a nice detail string + let detail = description || 'All systems operational'; + if (notOperational.length > 0) { + const sample = notOperational.slice(0, 3).map(c => `${c.name}`).join(', '); + const suffix = notOperational.length > 3 ? `, +${notOperational.length - 3} more` : ''; + if (finalStatus === STATUS.OFFLINE) { + detail = `Outage affecting: ${sample}${suffix}`; + } else { + detail = `Issues affecting: ${sample}${suffix}`; + } + } else if (finalStatus === STATUS.ISSUES) { + detail = description || 'Minor issues or maintenance in progress'; + } + + return { status: finalStatus, detail }; +} + +/** + * Check GitHub status via their public Statuspage endpoint. + */ +async function checkGitHub() { + const url = 'https://www.githubstatus.com/api/v2/status.json'; + const result = await safeGet(url); + + if (!result.ok) { + if (result.error) { + logger.warn('GitHub status check failed:', result.error.message || result.error.code); + } + return { + name: 'GitHub', + status: STATUS.OFFLINE, + ...STATUS_META[STATUS.OFFLINE], + emoji: getEmoji(STATUS_META[STATUS.OFFLINE]), + latency: result.responseTimeMs, + detail: result.error?.code + ? `Unable to reach GitHub status service (${result.error.code})` + : 'Unable to reach GitHub status service', + url, + }; + } + + const interpreted = interpretStatuspageStatus(result.data); + const meta = STATUS_META[interpreted.status]; + return { + name: 'GitHub', + status: interpreted.status, + ...meta, + emoji: getEmoji(meta), + latency: result.responseTimeMs, + detail: interpreted.detail, + url, + }; +} + +/** + * Check Railway status via their Instatus-powered public API. + * Docs: https://status.railway.com/public-api + */ +async function checkRailway() { + const summaryUrl = 'https://status.railway.com/summary.json'; + const componentsUrl = 'https://status.railway.com/v2/components.json'; + + const [summaryRes, componentsRes] = await Promise.all([ + safeGet(summaryUrl), + safeGet(componentsUrl), + ]); + + const responseTimeMs = Math.max(summaryRes.responseTimeMs, componentsRes.responseTimeMs); + + if (!summaryRes.ok && !componentsRes.ok) { + const err = summaryRes.error || componentsRes.error; + logger.warn('Railway status check failed:', err?.message || summaryRes.status); + return { + name: 'Railway', + status: STATUS.OFFLINE, + ...STATUS_META[STATUS.OFFLINE], + emoji: getEmoji(STATUS_META[STATUS.OFFLINE]), + latency: responseTimeMs, + detail: err?.code + ? `Unable to reach Railway status service (${err.code})` + : 'Unable to reach Railway status service', + url: 'https://status.railway.com/', + }; + } + + // If at least one request succeeds, interpret what we have + const interpreted = interpretInstatusSummary( + summaryRes.ok ? summaryRes.data : null, + componentsRes.ok ? componentsRes.data : null, + ); + const meta = STATUS_META[interpreted.status]; + return { + name: 'Railway', + status: interpreted.status, + ...meta, + emoji: getEmoji(meta), + latency: responseTimeMs, + detail: interpreted.detail, + url: 'https://status.railway.com/', + }; +} + +/** + * Check the Discord bot itself. Uses websocket heartbeat + client ready state. + */ +function checkDiscordBot(client) { + try { + const wsPing = Math.max(0, Math.round(client?.ws?.ping ?? -1)); + const readyState = client?.ws?.status; // 0 = connecting, 1 = ready (ws uses string in new djs) + const uptimeMs = client?.uptime ?? 0; + const isReady = !!client?.isReady?.() && client?.user; + + let status = STATUS.ONLINE; + let detail = 'Bot is online and responsive'; + + if (!isReady) { + status = STATUS.OFFLINE; + detail = 'Bot is not connected to Discord'; + } else if (wsPing <= 0 || wsPing > 1500) { + // WS ping <= 0 means we don't have a valid heartbeat yet + status = STATUS.ISSUES; + detail = wsPing <= 0 + ? 'Waiting for websocket heartbeat' + : 'High latency detected'; + } + + const meta = STATUS_META[status]; + return { + name: 'Discord Bot', + status, + ...meta, + emoji: getEmoji(meta), + latency: wsPing > 0 ? wsPing : null, + detail, + uptimeMs, + url: null, + }; + } catch (error) { + logger.error('Discord bot self-check failed:', error); + const meta = STATUS_META[STATUS.UNKNOWN]; + return { + name: 'Discord Bot', + status: STATUS.UNKNOWN, + ...meta, + emoji: getEmoji(meta), + latency: null, + detail: 'Unable to determine bot status', + uptimeMs: 0, + url: null, + }; + } +} + +/** + * Overall status precedence: OFFLINE > ISSUES > UNKNOWN > ONLINE. + */ +function computeOverallStatus(services) { + if (services.some(s => s.status === STATUS.OFFLINE)) return STATUS.OFFLINE; + if (services.some(s => s.status === STATUS.ISSUES)) return STATUS.ISSUES; + if (services.some(s => s.status === STATUS.UNKNOWN)) return STATUS.UNKNOWN; + return STATUS.ONLINE; +} + +/** + * Check all services in parallel with a top-level safety timeout. + * @param {import('discord.js').Client} client + */ +export async function checkAllServices(client) { + const timeoutMs = 8000; + + const checks = await Promise.all([ + checkGitHub().catch(err => { + logger.error('Unexpected error in checkGitHub:', err); + const meta = STATUS_META[STATUS.UNKNOWN]; + return { + name: 'GitHub', + status: STATUS.UNKNOWN, + ...meta, + emoji: getEmoji(meta), + latency: null, + detail: 'Unexpected error while checking GitHub status', + url: 'https://www.githubstatus.com/', + }; + }), + checkRailway().catch(err => { + logger.error('Unexpected error in checkRailway:', err); + const meta = STATUS_META[STATUS.UNKNOWN]; + return { + name: 'Railway', + status: STATUS.UNKNOWN, + ...meta, + emoji: getEmoji(meta), + latency: null, + detail: 'Unexpected error while checking Railway status', + url: 'https://status.railway.com/', + }; + }), + Promise.resolve().then(() => checkDiscordBot(client)).catch(err => { + logger.error('Unexpected error in checkDiscordBot:', err); + const meta = STATUS_META[STATUS.UNKNOWN]; + return { + name: 'Discord Bot', + status: STATUS.UNKNOWN, + ...meta, + emoji: getEmoji(meta), + latency: null, + detail: 'Unexpected error during bot self-check', + uptimeMs: 0, + url: null, + }; + }), + ]); + + // Enforce a hard wall-clock budget; Promise.all + internal timeouts should + // already keep us within budget, but this guards against a hung check. + const overall = computeOverallStatus(checks); + const overallMeta = STATUS_META[overall]; + + return { + services: checks, + overall: { + status: overall, + label: overallMeta.label, + emoji: getEmoji(overallMeta), + color: overallMeta.color, + }, + checkedAt: new Date(), + }; +} + +export { STATUS, STATUS_META };