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

@@ -10,7 +10,19 @@ const channelQueue = new PQueue({
});
function enqueueRename(channel, newName) {
return channelQueue.add(() => channel.setName(newName));
return channelQueue.add(async () => {
try {
await channel.setName(newName);
} catch (err) {
const msg = err?.message || String(err);
if (msg.includes('429') || msg.toLowerCase().includes('rate limit')) {
console.warn(`enqueueRename: rate limit renaming channel "${channel.name}"`);
return;
}
console.error('enqueueRename:', err);
throw err;
}
});
}
function enqueueMove(channel, categoryId) {

View File

@@ -46,8 +46,15 @@ function makeTicketName({ escalated, claimed }, ticket, guild) {
async function canRename(ticket) {
const now = Date.now();
const windowStart = (ticket.renameWindowStart && new Date(ticket.renameWindowStart).getTime()) || 0;
let count = ticket.renameCount || 0;
const fresh = await Ticket.findOne({ gmailThreadId: ticket.gmailThreadId })
.select('renameCount renameWindowStart')
.lean();
if (!fresh) {
return { ok: false, remaining: 0, waitMs: RENAME_WINDOW_MS };
}
const windowStart = (fresh.renameWindowStart && new Date(fresh.renameWindowStart).getTime()) || 0;
const count = fresh.renameCount || 0;
if (now - windowStart >= RENAME_WINDOW_MS) {
await Ticket.updateOne(
@@ -59,18 +66,28 @@ async function canRename(ticket) {
return { ok: true, remaining: RENAME_LIMIT, waitMs: 0 };
}
const remaining = RENAME_LIMIT - count;
if (remaining <= 0) {
if (count >= RENAME_LIMIT) {
const waitMs = RENAME_WINDOW_MS - (now - windowStart);
return { ok: false, remaining: 0, waitMs };
}
await Ticket.updateOne(
const updated = await Ticket.findOneAndUpdate(
{ gmailThreadId: ticket.gmailThreadId },
{ $inc: { renameCount: 1 } }
);
ticket.renameCount = count + 1;
return { ok: true, remaining: RENAME_LIMIT - (count + 1), waitMs: 0 };
{ $inc: { renameCount: 1 } },
{ returnDocument: 'after' }
)
.select('renameCount renameWindowStart')
.lean();
if (!updated) {
const waitMs = RENAME_WINDOW_MS - (now - windowStart);
return { ok: false, remaining: 0, waitMs };
}
const newCount = updated.renameCount || 0;
ticket.renameCount = newCount;
ticket.renameWindowStart = updated.renameWindowStart;
return { ok: true, remaining: RENAME_LIMIT - newCount, waitMs: 0 };
}
function minutesFromMs(ms) {
@@ -109,22 +126,124 @@ function checkTicketCreationRateLimit(userId) {
const CHANNELS_PER_CATEGORY_LIMIT = 50;
function escapeCategoryNameForRegex(name) {
return String(name).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Pick the first category that has room (< 50 channels). Main + overflow IDs in order.
* @param {import('discord.js').Guild} guild
* @param {string[]} categoryIds [mainId, ...overflowIds]
* @returns {string|null} category id to use as parent, or null
* @deprecated Use getOrCreateTicketCategory instead.
* @returns {null}
*/
function pickTicketCategoryId(guild, categoryIds) {
if (!guild || !Array.isArray(categoryIds)) return null;
const list = categoryIds.filter(Boolean);
for (const id of list) {
const cat = guild.channels.cache.get(id);
if (!cat || cat.type !== ChannelType.GuildCategory) continue;
const count = guild.channels.cache.filter(c => c.parentId === id).size;
if (count < CHANNELS_PER_CATEGORY_LIMIT) return id;
console.warn('[tickets] pickTicketCategoryId is deprecated; use getOrCreateTicketCategory() instead');
return null;
}
function countChannelsInCategory(guild, categoryId) {
return guild.channels.cache.filter(c => c.parentId === categoryId).size;
}
/**
* Resolve or create a ticket category with dynamic overflow (Discord max 50 channels per category).
* @param {import('discord.js').Guild} guild
* @param {string} primaryCategoryId
* @param {string} categoryName Display base name (primary category should match; overflows are "(Overflow N)")
* @returns {Promise<string>}
*/
async function getOrCreateTicketCategory(guild, primaryCategoryId, categoryName) {
if (!guild) {
throw new Error('getOrCreateTicketCategory: guild is required');
}
if (!primaryCategoryId || !String(primaryCategoryId).trim()) {
throw new Error('getOrCreateTicketCategory: primaryCategoryId is required');
}
try {
let primary = guild.channels.cache.get(primaryCategoryId);
if (!primary) {
primary = await guild.channels.fetch(primaryCategoryId).catch(() => null);
}
if (!primary || primary.type !== ChannelType.GuildCategory) {
throw new Error(`getOrCreateTicketCategory: primary category ${primaryCategoryId} not found or not a category`);
}
const escaped = escapeCategoryNameForRegex(categoryName);
const overflowRe = new RegExp(`^${escaped} \\(Overflow (\\d+)\\)$`);
const overflowMatches = [];
for (const ch of guild.channels.cache.values()) {
if (!ch || ch.type !== ChannelType.GuildCategory) continue;
if (ch.id === primaryCategoryId) continue;
const m = ch.name.match(overflowRe);
if (m) overflowMatches.push({ ch, n: parseInt(m[1], 10) });
}
overflowMatches.sort((a, b) => a.n - b.n);
const existingCategories = [primary, ...overflowMatches.map(x => x.ch)];
for (const cat of existingCategories) {
if (countChannelsInCategory(guild, cat.id) < CHANNELS_PER_CATEGORY_LIMIT) {
return cat.id;
}
}
const highestN = overflowMatches.length > 0 ? Math.max(...overflowMatches.map(x => x.n)) : 0;
const nextN = highestN + 1;
const newName = `${categoryName} (Overflow ${nextN})`;
const lastCat = existingCategories[existingCategories.length - 1];
const position = (lastCat?.rawPosition ?? lastCat?.position ?? 0) + 1;
let newCat;
try {
newCat = await guild.channels.create({
name: newName,
type: ChannelType.GuildCategory,
position
});
} catch (createErr) {
console.error('getOrCreateTicketCategory: failed to create overflow category:', createErr);
throw createErr;
}
return newCat.id;
} catch (err) {
console.error('getOrCreateTicketCategory:', err);
const fallback = guild.channels.cache.get(primaryCategoryId);
if (fallback?.type === ChannelType.GuildCategory) {
return primaryCategoryId;
}
throw err;
}
}
/**
* Delete an overflow category if it is empty and its name matches "${categoryName} (Overflow N)".
* Never deletes the primary category (exact name match).
* @param {import('discord.js').Guild} guild
* @param {string} categoryId
* @param {string} categoryName
*/
async function cleanupEmptyOverflowCategory(guild, categoryId, categoryName) {
try {
if (!guild || !categoryId) return;
const cached = guild.channels.cache.filter(c => c.parentId === categoryId);
if (cached.size !== 0) return;
let cat = guild.channels.cache.get(categoryId);
if (!cat) {
cat = await guild.channels.fetch(categoryId).catch(() => null);
}
if (!cat || cat.type !== ChannelType.GuildCategory) return;
if (cat.name === categoryName) return;
const escaped = escapeCategoryNameForRegex(categoryName);
const overflowRe = new RegExp(`^${escaped} \\(Overflow \\d+\\)$`);
if (!overflowRe.test(cat.name)) return;
await cat.delete().catch(deleteErr => {
console.error('cleanupEmptyOverflowCategory: delete failed:', deleteErr);
});
} catch (err) {
console.error('cleanupEmptyOverflowCategory:', err);
}
return list[0] || null;
}
async function createTicketChannel(guild, ticketNumber, userId, subject) {
@@ -155,39 +274,47 @@ async function createTicketChannel(guild, ticketNumber, userId, subject) {
}
return thread;
} else {
const categoryIds = [CONFIG.TICKET_CATEGORY_ID, ...(CONFIG.EMAIL_TICKET_OVERFLOW_CATEGORY_IDS || [])];
const parentId = pickTicketCategoryId(guild, categoryIds);
if (!parentId) {
throw new Error('Ticket category not found or all categories full (50 channels max per category)');
let parentId;
try {
parentId = await getOrCreateTicketCategory(guild, CONFIG.TICKET_CATEGORY_ID, CONFIG.TICKET_CATEGORY_NAME);
} catch (e) {
console.error('getOrCreateTicketCategory (createTicketChannel):', e);
throw new Error('Ticket category not found or could not be allocated');
}
const channel = await guild.channels.create({
name: `ticket-${ticketNumber}`,
type: ChannelType.GuildText,
parent: parentId,
permissionOverwrites: [
{
id: guild.id,
deny: [PermissionFlagsBits.ViewChannel]
},
{
id: userId,
allow: [
PermissionFlagsBits.ViewChannel,
PermissionFlagsBits.SendMessages,
PermissionFlagsBits.ReadMessageHistory
]
},
{
id: CONFIG.ROLE_ID_TO_PING,
allow: [
PermissionFlagsBits.ViewChannel,
PermissionFlagsBits.SendMessages,
PermissionFlagsBits.ReadMessageHistory
]
}
]
});
let channel;
try {
channel = await guild.channels.create({
name: `ticket-${ticketNumber}`,
type: ChannelType.GuildText,
parent: parentId,
permissionOverwrites: [
{
id: guild.id,
deny: [PermissionFlagsBits.ViewChannel]
},
{
id: userId,
allow: [
PermissionFlagsBits.ViewChannel,
PermissionFlagsBits.SendMessages,
PermissionFlagsBits.ReadMessageHistory
]
},
{
id: CONFIG.ROLE_ID_TO_PING,
allow: [
PermissionFlagsBits.ViewChannel,
PermissionFlagsBits.SendMessages,
PermissionFlagsBits.ReadMessageHistory
]
}
]
});
} catch (e) {
console.error('guild.channels.create (createTicketChannel):', e);
throw e;
}
return channel;
}
@@ -405,6 +532,8 @@ async function checkAutoUnclaim(client) {
module.exports = {
getNextTicketNumber,
pickTicketCategoryId,
getOrCreateTicketCategory,
cleanupEmptyOverflowCategory,
createDiscordTicketAsThread,
createEmailTicketAsThread,
RENAME_WINDOW_MS,