audit
This commit is contained in:
@@ -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 }
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user