Dynamic overflow categories
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user