This commit is contained in:
2026-04-20 18:05:36 +00:00
parent d73422555d
commit 33b1f276c6
26 changed files with 598 additions and 183 deletions

View File

@@ -7,7 +7,7 @@ const { mongoose, withRetry } = require('../db-connection');
const { CONFIG } = require('../config');
const { getPriorityEmoji } = require('../utils');
const { logAutomation } = require('../services/debugLog');
const { enqueueSend } = require('./channelQueue');
const { enqueueSend, enqueueDelete } = require('./channelQueue');
const Ticket = mongoose.model('Ticket');
const TicketCounter = mongoose.model('TicketCounter');
@@ -104,6 +104,26 @@ function minutesFromMs(ms) {
const ticketCreationByUser = new Map(); // userId -> { count, resetAt }
const TICKET_CREATION_SWEEP_TTL_MS = 48 * 60 * 60 * 1000;
const TICKET_CREATION_SWEEP_INTERVAL_MS = 6 * 60 * 60 * 1000;
function sweepTicketCreationByUser(now = Date.now()) {
// An entry is stale when its window has been expired long enough that no
// legitimate rate-limit decision would still consult it. resetAt is a future
// ms timestamp when the window ends; cutoff is 48h past that.
const cutoff = now - TICKET_CREATION_SWEEP_TTL_MS;
for (const [key, entry] of ticketCreationByUser.entries()) {
if ((entry?.resetAt ?? 0) < cutoff) ticketCreationByUser.delete(key);
}
}
function startTicketsSweeps(trackInterval) {
const handle = setInterval(() => sweepTicketCreationByUser(), TICKET_CREATION_SWEEP_INTERVAL_MS);
if (typeof handle.unref === 'function') handle.unref();
if (typeof trackInterval === 'function') trackInterval(handle);
return handle;
}
/**
* Check if the user can create a ticket (rate limit). If allowed, consumes one slot.
* @param {string} userId - Discord user ID
@@ -436,10 +456,11 @@ async function checkAutoClose(client, sendTicketClosedEmail) {
if (!CONFIG.AUTO_CLOSE_ENABLED) return;
const cutoffTime = new Date(Date.now() - (CONFIG.AUTO_CLOSE_AFTER_HOURS * 60 * 60 * 1000));
// Bounded per-tick so a huge backlog drains across successive hourly runs.
const staleTickets = await withRetry(() => Ticket.find({
status: 'open',
lastActivity: { $lt: cutoffTime, $ne: null }
}).lean());
}).sort({ createdAt: 1 }).limit(500).lean());
let checked = 0, closed = 0;
for (const ticket of staleTickets) {
@@ -452,14 +473,24 @@ async function checkAutoClose(client, sendTicketClosedEmail) {
if (channel) {
await enqueueSend(channel, CONFIG.DISCORD_AUTO_CLOSE_MESSAGE);
// Persist pendingDelete BEFORE the delay so a shutdown mid-delay can be
// resumed on boot via resumePendingDeletes(). Cleared after enqueueDelete
// resolves; if the doc is gone the unset is a no-op.
await withRetry(() => Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $set: { status: 'closed' } }
{ $set: { status: 'closed', pendingDelete: true } }
));
await sendTicketClosedEmail(ticket, 'Auto-Close System');
setTimeout(() => channel.delete().catch(() => {}), 5000);
setTimeout(() => {
enqueueDelete(channel).then(() => {
withRetry(() => Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $unset: { pendingDelete: '' } }
)).catch(() => {});
}).catch(() => {});
}, 5000);
closed++;
}
} catch (error) {
@@ -551,10 +582,11 @@ async function reconcileDeletedTicketChannels(client) {
const guild = client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID) || client.guilds.cache.first();
if (!guild) return { checked: 0, reconciled: 0 };
// Bounded per-tick; a larger backlog drains in subsequent hourly runs.
const openTickets = await Ticket.find({
status: 'open',
discordThreadId: { $ne: null }
}).lean();
}).sort({ createdAt: 1 }).limit(500).lean();
let checked = 0, reconciled = 0;
for (const ticket of openTickets) {
@@ -582,9 +614,39 @@ async function reconcileDeletedTicketChannels(client) {
return { checked, reconciled };
}
/**
* Resume deletes that were pending when the bot last shut down. Called once
* from the ready handler. Clears the flag regardless of fetch result so a
* stale flag (e.g. channel already gone) can't loop.
*/
async function resumePendingDeletes(client) {
const pending = await Ticket.find({ pendingDelete: true }).lean().catch(() => []);
if (!pending.length) return 0;
let resumed = 0;
for (const ticket of pending) {
try {
const guild = client.guilds.cache.first();
if (guild && ticket.discordThreadId) {
const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null);
if (channel) {
enqueueDelete(channel).catch(() => {});
resumed++;
}
}
Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $unset: { pendingDelete: '' } }
).catch(() => {});
} catch (e) {
console.error('resumePendingDeletes error:', e);
}
}
logAutomation('Pending-delete resume', null, `pending: ${pending.length}, resumed: ${resumed}`).catch(() => {});
return resumed;
}
module.exports = {
getNextTicketNumber,
pickTicketCategoryId,
getOrCreateTicketCategory,
cleanupEmptyOverflowCategory,
createDiscordTicketAsThread,
@@ -605,5 +667,9 @@ module.exports = {
checkAutoClose,
checkReminders,
checkAutoUnclaim,
reconcileDeletedTicketChannels
reconcileDeletedTicketChannels,
resumePendingDeletes,
startTicketsSweeps,
sweepTicketCreationByUser,
_internals: { ticketCreationByUser, TICKET_CREATION_SWEEP_TTL_MS }
};