Dynamic overflow categories

This commit is contained in:
indifferentketchup
2026-03-28 20:55:36 -05:00
parent 6b4fd65d4b
commit 1496a96274
10 changed files with 679 additions and 584 deletions

View File

@@ -16,7 +16,7 @@ const {
} = require('discord.js');
const { mongoose } = require('../db-connection');
const { CONFIG } = require('../config');
const { canRename, makeTicketName, minutesFromMs, pickTicketCategoryId, createDiscordTicketAsThread, checkTicketCreationRateLimit } = require('../services/tickets');
const { canRename, makeTicketName, minutesFromMs, getOrCreateTicketCategory, cleanupEmptyOverflowCategory, createDiscordTicketAsThread, checkTicketCreationRateLimit } = require('../services/tickets');
const { sendTicketClosedEmail } = require('../services/gmail');
const { getTicketActionRow } = require('../utils/ticketComponents');
const { setEmailRouting } = require('../services/guildSettings');
@@ -174,10 +174,13 @@ async function handleButton(interaction) {
return interaction.reply({ content: 'Tier 2 (ESCALATED2) is not configured for this ticket type.', ephemeral: true });
}
try {
await interaction.deferReply();
await runEscalation(interaction, ticket, 1, 'Escalated via button (Tier 2)');
} catch (err) {
trackError('escalate-button-tier2', err, interaction);
await interaction.reply({ content: 'Failed to escalate to tier 2.', ephemeral: true }).catch(() => {});
await interaction.editReply({ content: 'Failed to escalate to tier 2.' }).catch(() =>
interaction.followUp({ content: 'Failed to escalate to tier 2.', ephemeral: true }).catch(() => {})
);
}
return;
}
@@ -194,10 +197,13 @@ async function handleButton(interaction) {
return interaction.reply({ content: 'Tier 3 (ESCALATED3) is not configured for this ticket type.', ephemeral: true });
}
try {
await interaction.deferReply();
await runEscalation(interaction, ticket, 2, 'Escalated via button (Tier 3)');
} catch (err) {
trackError('escalate-button-tier3', err, interaction);
await interaction.reply({ content: 'Failed to escalate to tier 3.', ephemeral: true }).catch(() => {});
await interaction.editReply({ content: 'Failed to escalate to tier 3.' }).catch(() =>
interaction.followUp({ content: 'Failed to escalate to tier 3.', ephemeral: true }).catch(() => {})
);
}
return;
}
@@ -209,10 +215,13 @@ async function handleButton(interaction) {
return interaction.reply({ content: 'This ticket is not escalated.', ephemeral: true });
}
try {
await interaction.deferReply({ ephemeral: true });
await runDeescalation(interaction, ticket);
} catch (err) {
trackError('deescalate-button', err, interaction);
await interaction.reply({ content: 'Failed to deescalate this ticket.', ephemeral: true }).catch(() => {});
await interaction.editReply({ content: 'Failed to deescalate this ticket.' }).catch(() =>
interaction.followUp({ content: 'Failed to deescalate this ticket.', ephemeral: true }).catch(() => {})
);
}
return;
}
@@ -569,10 +578,20 @@ async function handleConfirmClose(interaction, ticket) {
});
}
const parentCatId = ticket.parentCategoryId;
const guildRef = interaction.guild;
setTimeout(
() => interaction.channel.delete().catch(() => {}),
5000
);
setTimeout(() => {
(async () => {
if (parentCatId && guildRef) {
await cleanupEmptyOverflowCategory(guildRef, parentCatId, CONFIG.TICKET_CATEGORY_NAME);
}
})();
}, 6000);
} catch (e) {
console.error('Close ticket error:', e);
}
@@ -606,9 +625,11 @@ async function handleTicketModal(interaction) {
const ticketNumber = (lastTicket?.ticketNumber || 0) + 1;
let channel;
let parentCategoryIdForTicket = null;
if (useThread && CONFIG.DISCORD_THREAD_CHANNEL_ID) {
try {
channel = await createDiscordTicketAsThread(guild, ticketNumber, interaction.user.id);
parentCategoryIdForTicket = channel.parent?.parentId ?? null;
} catch (err) {
console.error('Discord ticket thread create failed:', err.message);
return interaction.editReply('Could not create ticket thread. Check DISCORD_THREAD_CHANNEL_ID and try again.');
@@ -616,27 +637,39 @@ async function handleTicketModal(interaction) {
} else if (useThread && !CONFIG.DISCORD_THREAD_CHANNEL_ID) {
return interaction.editReply('Thread tickets are not configured (DISCORD_THREAD_CHANNEL_ID is not set). Use a channel panel or set the env variable.');
} else {
const categoryIds = [CONFIG.DISCORD_TICKET_CATEGORY_ID, ...(CONFIG.DISCORD_TICKET_OVERFLOW_CATEGORY_IDS || [])];
const parentId = pickTicketCategoryId(guild, categoryIds);
if (!parentId) {
return interaction.editReply('Discord ticket category not found or all categories full (50 channels max). Contact an administrator.');
let parentId;
try {
parentId = await getOrCreateTicketCategory(
guild,
CONFIG.DISCORD_TICKET_CATEGORY_ID,
CONFIG.TICKET_CATEGORY_NAME
);
} catch (err) {
console.error('getOrCreateTicketCategory (ticket modal):', err);
return interaction.editReply('Discord ticket category could not be resolved. Contact an administrator.');
}
parentCategoryIdForTicket = parentId;
try {
channel = await guild.channels.create({
name: `ticket-${ticketNumber}`,
type: ChannelType.GuildText,
parent: parentId,
permissionOverwrites: [
{ id: guild.id, deny: [PermissionFlagsBits.ViewChannel] },
{
id: interaction.user.id,
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
},
{
id: CONFIG.ROLE_ID_TO_PING,
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
}
]
});
} catch (err) {
console.error('guild.channels.create (ticket modal):', err);
return interaction.editReply('Failed to create ticket channel. Contact an administrator.');
}
channel = await guild.channels.create({
name: `ticket-${ticketNumber}`,
type: ChannelType.GuildText,
parent: parentId,
permissionOverwrites: [
{ id: guild.id, deny: [PermissionFlagsBits.ViewChannel] },
{
id: interaction.user.id,
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
},
{
id: CONFIG.ROLE_ID_TO_PING,
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
}
]
});
}
const gmailThreadId = `discord-${Date.now()}-${interaction.user.id}`;
@@ -651,7 +684,8 @@ async function handleTicketModal(interaction) {
status: 'open',
ticketNumber,
priority,
lastActivity: now
lastActivity: now,
parentCategoryId: parentCategoryIdForTicket
});
const displayName = interaction.member?.displayName || interaction.user.username;

View File

@@ -13,7 +13,7 @@ const {
const { mongoose } = require('../db-connection');
const { CONFIG, TICKET_TAGS } = require('../config');
const { getPriorityEmoji, getPriorityColor, replaceVariables, escapeRegex } = require('../utils');
const { canRename, makeTicketName, pickTicketCategoryId, createDiscordTicketAsThread, checkTicketCreationRateLimit } = require('../services/tickets');
const { canRename, makeTicketName, getOrCreateTicketCategory, createDiscordTicketAsThread, checkTicketCreationRateLimit } = require('../services/tickets');
const { sendTicketNotificationEmail } = require('../services/gmail');
const { getTicketActionRow } = require('../utils/ticketComponents');
const { getEmailRouting } = require('../services/guildSettings');
@@ -118,7 +118,7 @@ async function runEscalation(interaction, ticket, nextTier, reason) {
const pendingEmbed = new EmbedBuilder()
.setDescription('Ticket will be escalated in a few seconds.')
.setColor(CONFIG.EMBED_COLOR_INFO);
await interaction.reply({ content: null, embeds: [pendingEmbed] });
await interaction.editReply({ embeds: [pendingEmbed] });
const creatorId = isDiscordTicket
? (ticket.gmailThreadId.split('-').pop() || '').trim()
@@ -228,10 +228,7 @@ async function runDeescalation(interaction, ticket) {
.setColor(0x00BFFF)
.setTitle(`✅ De-escalated to ${tierLabel} Support`)
.setFooter({ text: interaction.member?.displayName || interaction.user.username });
await interaction.reply({
embeds: [deescalateEmbed],
ephemeral: true
});
await interaction.editReply({ embeds: [deescalateEmbed] });
const logChan = await interaction.client.channels.fetch(CONFIG.LOG_CHAN).catch(() => null);
if (logChan) {
@@ -316,6 +313,7 @@ async function handleCommand(interaction) {
}
try {
await interaction.deferReply();
await runEscalation(interaction, ticket, nextTier, reason);
if (action === 'unclaim') {
await Ticket.updateOne(
@@ -325,7 +323,9 @@ async function handleCommand(interaction) {
}
} catch (err) {
console.error('Escalate error:', err);
await interaction.reply({ content: 'Failed to escalate this ticket.', ephemeral: true });
await interaction.editReply({ content: 'Failed to escalate this ticket.' }).catch(() =>
interaction.followUp({ content: 'Failed to escalate this ticket.', ephemeral: true }).catch(() => {})
);
}
}
@@ -357,10 +357,13 @@ async function handleCommand(interaction) {
}
try {
await interaction.deferReply({ ephemeral: true });
await runDeescalation(interaction, ticket);
} catch (err) {
console.error('Deescalate error:', err);
await interaction.reply({ content: 'Failed to deescalate this ticket.', ephemeral: true });
await interaction.editReply({ content: 'Failed to deescalate this ticket.' }).catch(() =>
interaction.followUp({ content: 'Failed to deescalate this ticket.', ephemeral: true }).catch(() => {})
);
}
}
@@ -1044,35 +1047,49 @@ async function handleContextMenu(interaction) {
const ticketNumber = (lastTicket?.ticketNumber || 0) + 1;
let channel;
let parentCategoryIdForTicket = null;
if (CONFIG.DISCORD_THREAD_CHANNEL_ID) {
try {
channel = await createDiscordTicketAsThread(guild, ticketNumber, message.author.id);
parentCategoryIdForTicket = channel.parent?.parentId ?? null;
} catch (err) {
console.error('Discord ticket thread create (from message) failed:', err.message);
return interaction.editReply('❌ Could not create ticket thread. Check DISCORD_THREAD_CHANNEL_ID.');
}
} else {
const categoryIds = [CONFIG.DISCORD_TICKET_CATEGORY_ID, ...(CONFIG.DISCORD_TICKET_OVERFLOW_CATEGORY_IDS || [])];
const parentId = pickTicketCategoryId(guild, categoryIds);
if (!parentId) {
return interaction.editReply('❌ Discord ticket category not found or all categories full (50 channels max). Contact an administrator.');
let parentId;
try {
parentId = await getOrCreateTicketCategory(
guild,
CONFIG.DISCORD_TICKET_CATEGORY_ID,
CONFIG.TICKET_CATEGORY_NAME
);
} catch (err) {
console.error('getOrCreateTicketCategory (context menu ticket):', err);
return interaction.editReply('❌ Discord ticket category could not be resolved. Contact an administrator.');
}
parentCategoryIdForTicket = parentId;
try {
channel = await guild.channels.create({
name: `ticket-${ticketNumber}`,
type: ChannelType.GuildText,
parent: parentId,
permissionOverwrites: [
{ id: guild.id, deny: [PermissionFlagsBits.ViewChannel] },
{
id: message.author.id,
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
},
{
id: CONFIG.ROLE_ID_TO_PING,
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
}
]
});
} catch (err) {
console.error('guild.channels.create (context menu ticket):', err);
return interaction.editReply('❌ Failed to create ticket channel. Contact an administrator.');
}
channel = await guild.channels.create({
name: `ticket-${ticketNumber}`,
type: ChannelType.GuildText,
parent: parentId,
permissionOverwrites: [
{ id: guild.id, deny: [PermissionFlagsBits.ViewChannel] },
{
id: message.author.id,
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
},
{
id: CONFIG.ROLE_ID_TO_PING,
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
}
]
});
}
const gmailThreadId = `discord-msg-${Date.now()}-${message.id}`;
@@ -1086,7 +1103,8 @@ async function handleContextMenu(interaction) {
status: 'open',
ticketNumber,
priority: 'normal',
lastActivity: now
lastActivity: now,
parentCategoryId: parentCategoryIdForTicket
});
const welcomeEmbed = new EmbedBuilder()