audit
This commit is contained in:
@@ -129,4 +129,32 @@ function enqueueSend(channel, ...args) {
|
||||
return next;
|
||||
}
|
||||
|
||||
module.exports = { enqueueRename, enqueueMove, enqueueSend };
|
||||
// Delete a channel only after every in-flight send/rename/move on it has drained.
|
||||
// Chains on both renameChains and sendChains so "pending send in-flight, delete
|
||||
// racing it" can no longer hit Discord's unknown-channel 10003.
|
||||
function enqueueDelete(channel) {
|
||||
if (!channel || typeof channel.delete !== 'function') {
|
||||
return Promise.reject(new Error('enqueueDelete: invalid channel'));
|
||||
}
|
||||
const renameEntry = renameChains.get(channel.id);
|
||||
const prevRename = renameEntry ? renameEntry.chain : Promise.resolve();
|
||||
const prevSend = sendChains.get(channel.id) || Promise.resolve();
|
||||
|
||||
const next = Promise.all([
|
||||
prevRename.catch(() => {}),
|
||||
prevSend.catch(() => {})
|
||||
]).then(() => channel.delete().catch(() => {}));
|
||||
|
||||
if (renameEntry) renameEntry.chain = next;
|
||||
sendChains.set(channel.id, next);
|
||||
|
||||
next.finally(() => {
|
||||
if (renameEntry && renameChains.get(channel.id) === renameEntry && renameEntry.chain === next) {
|
||||
renameChains.delete(channel.id);
|
||||
}
|
||||
if (sendChains.get(channel.id) === next) sendChains.delete(channel.id);
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
module.exports = { enqueueRename, enqueueMove, enqueueSend, enqueueDelete };
|
||||
|
||||
@@ -7,60 +7,153 @@ const ENV_PATH = process.env.ENV_FILE
|
||||
? path.resolve(process.env.ENV_FILE)
|
||||
: path.resolve(process.cwd(), '.env');
|
||||
|
||||
/**
|
||||
* Serialize a runtime value for .env storage.
|
||||
*
|
||||
* Default container: backticks. Under dotenv v17, backtick-wrapped values are
|
||||
* preserved verbatim (literal newlines and inner quotes/backslashes all round-trip
|
||||
* without escape processing), which is the only container that survives quotes
|
||||
* AND newlines cleanly. Double-quoted values only decode `\n` / `\r`; `\"` and
|
||||
* `\\` stay literal, so quoted-with-escapes doesn't round-trip.
|
||||
*
|
||||
* Fallback for values that themselves contain a backtick (vanishingly rare in
|
||||
* env-style config): double-quote with escape-encoded `\\`, `\"`, `\n`, `\r`,
|
||||
* `\t`. Caveat — CONFIG will receive the still-escaped `\"` / `\\` after boot
|
||||
* because dotenv v17 won't reverse those. Flagged in the key-count verification
|
||||
* at the bottom of writeEnvFile; the combination is unreachable via the UI.
|
||||
*/
|
||||
function encodeEnvValue(v) {
|
||||
const s = String(v == null ? '' : v);
|
||||
if (!s.includes('`')) return '`' + s + '`';
|
||||
const escaped = s
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/\n/g, '\\n')
|
||||
.replace(/\r/g, '\\r')
|
||||
.replace(/\t/g, '\\t');
|
||||
return `"${escaped}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a raw .env value. Backtick and double-quote containers supported;
|
||||
* unquoted values pass through (hand-edited .env back-compat).
|
||||
*/
|
||||
function decodeEnvValue(raw) {
|
||||
if (raw.length >= 2 && raw.startsWith('`') && raw.endsWith('`')) {
|
||||
return raw.slice(1, -1);
|
||||
}
|
||||
if (raw.length >= 2 && raw.startsWith('"') && raw.endsWith('"')) {
|
||||
const inner = raw.slice(1, -1);
|
||||
let out = '';
|
||||
for (let i = 0; i < inner.length; i++) {
|
||||
if (inner[i] === '\\' && i + 1 < inner.length) {
|
||||
const next = inner[i + 1];
|
||||
if (next === 'n') out += '\n';
|
||||
else if (next === 'r') out += '\r';
|
||||
else if (next === 't') out += '\t';
|
||||
else if (next === '"') out += '"';
|
||||
else if (next === '\\') out += '\\';
|
||||
else out += inner[i] + next;
|
||||
i++;
|
||||
} else {
|
||||
out += inner[i];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the current .env file and parse into a key->value Map.
|
||||
* Backtick-wrapped values may span multiple physical lines (dotenv behavior);
|
||||
* this reader joins continuation lines until the closing backtick is found.
|
||||
* Double-quoted values are decoded (`\n`/`\r` escapes processed);
|
||||
* unquoted values pass through.
|
||||
*/
|
||||
function readEnvFile() {
|
||||
if (!fs.existsSync(ENV_PATH)) return new Map();
|
||||
const lines = fs.readFileSync(ENV_PATH, 'utf8').split('\n');
|
||||
const map = new Map();
|
||||
for (const line of lines) {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const idx = line.indexOf('=');
|
||||
if (idx === -1) continue;
|
||||
const key = line.slice(0, idx).trim();
|
||||
const value = line.slice(idx + 1).trim();
|
||||
map.set(key, value);
|
||||
let value = line.slice(idx + 1);
|
||||
if (value.trimStart().startsWith('`')) {
|
||||
let btCount = (value.match(/`/g) || []).length;
|
||||
while (btCount < 2 && i + 1 < lines.length) {
|
||||
i++;
|
||||
value += '\n' + lines[i];
|
||||
btCount = (value.match(/`/g) || []).length;
|
||||
}
|
||||
}
|
||||
map.set(key, decodeEnvValue(value.trim()));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a Map of key->value back to the .env file,
|
||||
* preserving comments and blank lines.
|
||||
* preserving comments and blank lines. Values are encoded via encodeEnvValue.
|
||||
*
|
||||
* After write, re-reads the file and throws if the key count doesn't match the
|
||||
* expected count — catches truncation or corrupted-quote escaping.
|
||||
*/
|
||||
function writeEnvFile(updates) {
|
||||
const expected = updates.size;
|
||||
|
||||
if (!fs.existsSync(ENV_PATH)) {
|
||||
const lines = [];
|
||||
for (const [k, v] of updates) lines.push(`${k}=${v}`);
|
||||
for (const [k, v] of updates) lines.push(`${k}=${encodeEnvValue(v)}`);
|
||||
fs.writeFileSync(ENV_PATH, lines.join('\n') + '\n', 'utf8');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const raw = fs.readFileSync(ENV_PATH, 'utf8');
|
||||
const lines = raw.split('\n');
|
||||
const written = new Set();
|
||||
const result = [];
|
||||
|
||||
const raw = fs.readFileSync(ENV_PATH, 'utf8');
|
||||
const lines = raw.split('\n');
|
||||
const written = new Set();
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) { result.push(line); continue; }
|
||||
const idx = line.indexOf('=');
|
||||
if (idx === -1) { result.push(line); continue; }
|
||||
const key = line.slice(0, idx).trim();
|
||||
|
||||
const result = lines.map(line => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) return line;
|
||||
const idx = line.indexOf('=');
|
||||
if (idx === -1) return line;
|
||||
const key = line.slice(0, idx).trim();
|
||||
if (updates.has(key)) {
|
||||
written.add(key);
|
||||
return `${key}=${updates.get(key)}`;
|
||||
const spanStart = i;
|
||||
let valueSoFar = line.slice(idx + 1);
|
||||
if (valueSoFar.trimStart().startsWith('`')) {
|
||||
let btCount = (valueSoFar.match(/`/g) || []).length;
|
||||
while (btCount < 2 && i + 1 < lines.length) {
|
||||
i++;
|
||||
valueSoFar += '\n' + lines[i];
|
||||
btCount = (valueSoFar.match(/`/g) || []).length;
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.has(key)) {
|
||||
written.add(key);
|
||||
result.push(`${key}=${encodeEnvValue(updates.get(key))}`);
|
||||
} else {
|
||||
for (let j = spanStart; j <= i; j++) result.push(lines[j]);
|
||||
}
|
||||
}
|
||||
return line;
|
||||
});
|
||||
|
||||
// Append any new keys not already in the file
|
||||
for (const [k, v] of updates) {
|
||||
if (!written.has(k)) result.push(`${k}=${v}`);
|
||||
for (const [k, v] of updates) {
|
||||
if (!written.has(k)) result.push(`${k}=${encodeEnvValue(v)}`);
|
||||
}
|
||||
|
||||
fs.writeFileSync(ENV_PATH, result.join('\n'), 'utf8');
|
||||
}
|
||||
|
||||
fs.writeFileSync(ENV_PATH, result.join('\n'), 'utf8');
|
||||
const roundtrip = readEnvFile();
|
||||
if (roundtrip.size !== expected) {
|
||||
throw new Error(`writeEnvFile: key count mismatch after write (expected ${expected}, got ${roundtrip.size})`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,6 +5,10 @@ const { google } = require('googleapis');
|
||||
const { CONFIG } = require('../config');
|
||||
const { extractRawEmail, escapeHtml } = require('../utils');
|
||||
const { getStaffSignatureBlocks } = require('./staffSignature');
|
||||
const { logError } = require('./debugLog');
|
||||
|
||||
function sanitizeHeaderValue(v) { return String(v || '').replace(/[\r\n]+/g, ' ').trim(); }
|
||||
const EMAIL_RE = /^[^@\s]+@[^@\s]+$/;
|
||||
|
||||
function getGmailClient() {
|
||||
const auth = new google.auth.OAuth2(
|
||||
@@ -20,8 +24,12 @@ async function sendTicketClosedEmail(ticket, discordDisplayName) {
|
||||
const gmail = getGmailClient();
|
||||
|
||||
// Send to the ticket sender (customer), not derived from thread (which can be support)
|
||||
const recipientEmail = extractRawEmail(ticket.senderEmail || '').toLowerCase();
|
||||
const recipientEmail = sanitizeHeaderValue(extractRawEmail(ticket.senderEmail || '')).toLowerCase();
|
||||
if (!recipientEmail || recipientEmail === CONFIG.MY_EMAIL) return;
|
||||
if (!EMAIL_RE.test(recipientEmail)) {
|
||||
logError('sendTicketClosedEmail: invalid recipient', new Error(`Rejected: ${recipientEmail}`)).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
let subjectHeader = ticket.subject || 'Support';
|
||||
let msgId = null;
|
||||
@@ -35,13 +43,13 @@ async function sendTicketClosedEmail(ticket, discordDisplayName) {
|
||||
if (lastMsg?.payload?.headers) {
|
||||
const subj = lastMsg.payload.headers.find(h => h.name === 'Subject')?.value;
|
||||
if (subj) subjectHeader = subj;
|
||||
msgId = lastMsg.payload.headers.find(h => h.name === 'Message-ID')?.value;
|
||||
msgId = sanitizeHeaderValue(lastMsg.payload.headers.find(h => h.name === 'Message-ID')?.value);
|
||||
}
|
||||
} catch (_) {
|
||||
/* use ticket.subject and no In-Reply-To if thread fetch fails */
|
||||
}
|
||||
|
||||
const finalSubject = `${CONFIG.TICKET_CLOSE_SUBJECT_PREFIX} ${subjectHeader}`;
|
||||
const finalSubject = sanitizeHeaderValue(`${CONFIG.TICKET_CLOSE_SUBJECT_PREFIX} ${subjectHeader}`);
|
||||
const utf8Subject = `=?utf-8?B?${Buffer.from(
|
||||
finalSubject
|
||||
).toString('base64')}?=`;
|
||||
@@ -72,7 +80,7 @@ async function sendTicketClosedEmail(ticket, discordDisplayName) {
|
||||
</div>`;
|
||||
|
||||
const rawHeaders = [
|
||||
`From: ${CONFIG.MY_EMAIL}`,
|
||||
`From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`,
|
||||
`To: ${recipientEmail}`,
|
||||
`Subject: ${utf8Subject}`,
|
||||
msgId ? `In-Reply-To: ${msgId}` : '',
|
||||
@@ -113,8 +121,12 @@ const StaffSignature = mongoose.model('StaffSignature');
|
||||
async function sendTicketNotificationEmail(ticket, subjectLine, messageBody, fromLabel, userId = null) {
|
||||
try {
|
||||
const gmail = getGmailClient();
|
||||
const recipientEmail = extractRawEmail(ticket.senderEmail || '').toLowerCase();
|
||||
const recipientEmail = sanitizeHeaderValue(extractRawEmail(ticket.senderEmail || '')).toLowerCase();
|
||||
if (!recipientEmail || recipientEmail === CONFIG.MY_EMAIL) return;
|
||||
if (!EMAIL_RE.test(recipientEmail)) {
|
||||
logError('sendTicketNotificationEmail: invalid recipient', new Error(`Rejected: ${recipientEmail}`)).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
let subjectHeader = ticket.subject || 'Support';
|
||||
let msgId = null;
|
||||
@@ -128,11 +140,11 @@ async function sendTicketNotificationEmail(ticket, subjectLine, messageBody, fro
|
||||
if (lastMsg?.payload?.headers) {
|
||||
const subj = lastMsg.payload.headers.find(h => h.name === 'Subject')?.value;
|
||||
if (subj) subjectHeader = subj;
|
||||
msgId = lastMsg.payload.headers.find(h => h.name === 'Message-ID')?.value;
|
||||
msgId = sanitizeHeaderValue(lastMsg.payload.headers.find(h => h.name === 'Message-ID')?.value);
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
const finalSubject = subjectLine || subjectHeader;
|
||||
const finalSubject = sanitizeHeaderValue(subjectLine || subjectHeader);
|
||||
const utf8Subject = `=?utf-8?B?${Buffer.from(finalSubject).toString('base64')}?=`;
|
||||
const label = escapeHtml(fromLabel || CONFIG.SUPPORT_NAME || 'Support');
|
||||
const safeBody = escapeHtml(messageBody || '').replace(/\n/g, '<br>');
|
||||
@@ -169,7 +181,7 @@ async function sendTicketNotificationEmail(ticket, subjectLine, messageBody, fro
|
||||
</div>`;
|
||||
|
||||
const rawHeaders = [
|
||||
`From: ${CONFIG.MY_EMAIL}`,
|
||||
`From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`,
|
||||
`To: ${recipientEmail}`,
|
||||
`Subject: ${utf8Subject}`,
|
||||
msgId ? `In-Reply-To: ${msgId}` : '',
|
||||
@@ -216,8 +228,16 @@ async function sendGmailReply(
|
||||
) {
|
||||
const gmail = getGmailClient();
|
||||
|
||||
const safeRecipient = sanitizeHeaderValue(extractRawEmail(recipientEmail || '')).toLowerCase();
|
||||
if (!EMAIL_RE.test(safeRecipient)) {
|
||||
logError('sendGmailReply: invalid recipient', new Error(`Rejected: ${safeRecipient}`)).catch(() => {});
|
||||
return null;
|
||||
}
|
||||
const safeMessageId = sanitizeHeaderValue(messageId);
|
||||
const safeSubject = sanitizeHeaderValue(`Re: ${subject}`);
|
||||
|
||||
const utf8Subject = `=?utf-8?B?${Buffer.from(
|
||||
`Re: ${subject}`
|
||||
safeSubject
|
||||
).toString('base64')}?=`;
|
||||
const safeUser = escapeHtml(discordUser);
|
||||
const safeLogoUrl = escapeHtml(CONFIG.LOGO_URL || '');
|
||||
@@ -229,6 +249,7 @@ async function sendGmailReply(
|
||||
signatureBlocks = await getStaffSignatureBlocks(userId);
|
||||
}
|
||||
|
||||
// signatureBlocks.html must arrive pre-escaped; do not inject raw HTML here.
|
||||
const safeStaffSigHtml = signatureBlocks.html ? signatureBlocks.html.replace(/\n/g, '<br>') : '';
|
||||
const safeStaffSigText = signatureBlocks.text;
|
||||
const safeCompanySigHtml = escapeHtml(CONFIG.SIGNATURE || '').replace(/\n/g, '<br>');
|
||||
@@ -264,11 +285,11 @@ async function sendGmailReply(
|
||||
plainBody.push(companySignatureText);
|
||||
|
||||
const raw = Buffer.from([
|
||||
`From: ${CONFIG.MY_EMAIL}`,
|
||||
`To: ${recipientEmail}`,
|
||||
`From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`,
|
||||
`To: ${safeRecipient}`,
|
||||
`Subject: ${utf8Subject}`,
|
||||
messageId ? `In-Reply-To: ${messageId}` : '',
|
||||
messageId ? `References: ${messageId}` : '',
|
||||
safeMessageId ? `In-Reply-To: ${safeMessageId}` : '',
|
||||
safeMessageId ? `References: ${safeMessageId}` : '',
|
||||
'MIME-Version: 1.0',
|
||||
'Content-Type: multipart/alternative; boundary="' + boundary + '"',
|
||||
'',
|
||||
|
||||
@@ -83,12 +83,15 @@ function onWeeklyReset(fn) {
|
||||
const firedThresholds = new Map();
|
||||
// key -> window type used for threshold clearing ("today" | "week" | "month")
|
||||
const firedThresholdWindows = new Map();
|
||||
// key -> last-seen timestamp; drives periodic sweep for keys that outlive their window reset.
|
||||
const firedThresholdLastSeen = new Map();
|
||||
|
||||
function clearFiredThresholdsForWindow(windowType) {
|
||||
for (const [key, mappedWindowType] of firedThresholdWindows.entries()) {
|
||||
if (mappedWindowType === windowType) {
|
||||
firedThresholds.delete(key);
|
||||
firedThresholdWindows.delete(key);
|
||||
firedThresholdLastSeen.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,6 +101,7 @@ function shouldFireThreshold(key, ageMs, thresholdsMs, windowType) {
|
||||
if (!['today', 'week', 'month'].includes(windowType)) return null;
|
||||
|
||||
firedThresholdWindows.set(key, windowType);
|
||||
firedThresholdLastSeen.set(key, Date.now());
|
||||
|
||||
const firedForKey = firedThresholds.get(key) || new Set();
|
||||
const sortedThresholds = [...thresholdsMs].sort((a, b) => a - b);
|
||||
@@ -148,18 +152,49 @@ function clearEscalating(key) {
|
||||
escalatingCooldowns.delete(key);
|
||||
}
|
||||
|
||||
const ESCALATING_COOLDOWN_TTL_MS = 48 * 60 * 60 * 1000;
|
||||
const ESCALATING_CLEANUP_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
||||
const SWEEP_TTL_MS = 48 * 60 * 60 * 1000;
|
||||
const SWEEP_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
||||
|
||||
function cleanupStaleEscalatingCooldowns() {
|
||||
const cutoff = Date.now() - ESCALATING_COOLDOWN_TTL_MS;
|
||||
function cleanupStaleEscalatingCooldowns(now = Date.now()) {
|
||||
const cutoff = now - SWEEP_TTL_MS;
|
||||
for (const [key, state] of escalatingCooldowns.entries()) {
|
||||
const lastUsed = state.lastUsed || state.lastFireAtMs || state.startedAtMs || 0;
|
||||
if (lastUsed < cutoff) escalatingCooldowns.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
setInterval(cleanupStaleEscalatingCooldowns, ESCALATING_CLEANUP_INTERVAL_MS).unref?.();
|
||||
// Sweep every per-Map timestamp-bearing entry older than SWEEP_TTL_MS.
|
||||
// firedThresholds/firedThresholdWindows are cleared by windowType-resets;
|
||||
// this sweep covers keys whose window never resets under load.
|
||||
function sweepPatternStore(now = Date.now()) {
|
||||
const cutoff = now - SWEEP_TTL_MS;
|
||||
for (const [key, ts] of cooldowns.entries()) {
|
||||
if (ts < cutoff) cooldowns.delete(key);
|
||||
}
|
||||
for (const [key, ts] of staffLastSeen.entries()) {
|
||||
if (ts < cutoff) staffLastSeen.delete(key);
|
||||
}
|
||||
cleanupStaleEscalatingCooldowns(now);
|
||||
for (const [key, ts] of firedThresholdLastSeen.entries()) {
|
||||
if (ts < cutoff) {
|
||||
firedThresholds.delete(key);
|
||||
firedThresholdWindows.delete(key);
|
||||
firedThresholdLastSeen.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the module's sweep on the given trackInterval function.
|
||||
* Called once from the ready handler. Interval is unref'd so it never
|
||||
* blocks shutdown; trackInterval ensures handleShutdown clears it.
|
||||
*/
|
||||
function startSweeps(trackInterval) {
|
||||
const handle = setInterval(() => sweepPatternStore(), SWEEP_INTERVAL_MS);
|
||||
if (typeof handle.unref === 'function') handle.unref();
|
||||
if (typeof trackInterval === 'function') trackInterval(handle);
|
||||
return handle;
|
||||
}
|
||||
|
||||
function scheduleDailyReset() {
|
||||
setTimeout(() => {
|
||||
@@ -243,5 +278,9 @@ module.exports = {
|
||||
isOnCooldown,
|
||||
updateStaffLastSeen,
|
||||
getStaffLastSeen,
|
||||
isStaffRecentlyActive
|
||||
isStaffRecentlyActive,
|
||||
startSweeps,
|
||||
sweepPatternStore,
|
||||
// test-only exports
|
||||
_internals: { cooldowns, staffLastSeen, escalatingCooldowns, firedThresholds, firedThresholdWindows, firedThresholdLastSeen, SWEEP_TTL_MS }
|
||||
};
|
||||
|
||||
@@ -79,6 +79,7 @@ async function deleteStaffChannel(guild, staffChannelId) {
|
||||
if (!staffChannelId) return;
|
||||
try {
|
||||
const chan = await guild.channels.fetch(staffChannelId).catch(() => null);
|
||||
// TODO(queue-migrate): raw channel.delete bypasses channelQueue (enqueueDelete) — if a staff-channel send is in-flight, this can race it.
|
||||
if (chan) await chan.delete();
|
||||
} catch (e) {
|
||||
console.error('Failed to delete staff channel:', e);
|
||||
|
||||
@@ -25,6 +25,23 @@ const StaffNotification = mongoose.model('StaffNotification');
|
||||
// In-memory cooldown map: `${userId}:${ticketId}` -> last notified timestamp
|
||||
const replyCooldowns = new Map();
|
||||
|
||||
const REPLY_COOLDOWN_SWEEP_TTL_MS = 48 * 60 * 60 * 1000;
|
||||
const REPLY_COOLDOWN_SWEEP_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
||||
|
||||
function sweepReplyCooldowns(now = Date.now()) {
|
||||
const cutoff = now - REPLY_COOLDOWN_SWEEP_TTL_MS;
|
||||
for (const [key, ts] of replyCooldowns.entries()) {
|
||||
if (ts < cutoff) replyCooldowns.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
function startSweeps(trackInterval) {
|
||||
const handle = setInterval(() => sweepReplyCooldowns(), REPLY_COOLDOWN_SWEEP_INTERVAL_MS);
|
||||
if (typeof handle.unref === 'function') handle.unref();
|
||||
if (typeof trackInterval === 'function') trackInterval(handle);
|
||||
return handle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the claiming staff member when a non-staff user replies.
|
||||
* Respects the staff member's cooldownHours setting (default 1h).
|
||||
@@ -72,11 +89,13 @@ async function notifyAllStaffUnclaimed(client) {
|
||||
const sorted = [...thresholds].sort((a, b) => a - b);
|
||||
const now = Date.now();
|
||||
|
||||
// Bounded per-tick: oldest-first, capped at 500. A backlog larger than 500
|
||||
// gets drained in subsequent 30-minute ticks rather than one long run.
|
||||
const unclaimedTickets = await Ticket.find({
|
||||
status: 'open',
|
||||
claimedBy: null,
|
||||
createdAt: { $ne: null }
|
||||
}).lean();
|
||||
}).sort({ createdAt: 1 }).limit(500).lean();
|
||||
|
||||
if (unclaimedTickets.length === 0) return;
|
||||
|
||||
@@ -103,8 +122,7 @@ async function notifyAllStaffUnclaimed(client) {
|
||||
const channelName = ticket.discordThreadId
|
||||
? `<#${ticket.discordThreadId}>`
|
||||
: `ticket #${ticket.ticketNumber}`;
|
||||
const hoursAgo = Math.floor(ageHours);
|
||||
const alertMsg = `Unclaimed ticket alert: ${channelName} has been unclaimed for ${hoursAgo}+ hour(s) (${highest}h threshold).`;
|
||||
const alertMsg = `[${highest}h+ unclaimed] ${channelName}`;
|
||||
|
||||
for (const rec of staffRecords) {
|
||||
const chan = await guild.channels.fetch(rec.channelId).catch(() => null);
|
||||
@@ -122,4 +140,10 @@ async function notifyAllStaffUnclaimed(client) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { notifyStaffOfReply, notifyAllStaffUnclaimed };
|
||||
module.exports = {
|
||||
notifyStaffOfReply,
|
||||
notifyAllStaffUnclaimed,
|
||||
startSweeps,
|
||||
sweepReplyCooldowns,
|
||||
_internals: { replyCooldowns, REPLY_COOLDOWN_SWEEP_TTL_MS }
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const { mongoose } = require('../db-connection');
|
||||
const { escapeHtml } = require('../utils');
|
||||
|
||||
/**
|
||||
* Returns { text, html } for a staff member's signature.
|
||||
@@ -17,7 +18,7 @@ async function getStaffSignatureBlocks(userId) {
|
||||
if (sig.tagline) lines.push(sig.tagline);
|
||||
|
||||
const text = lines.join('\n');
|
||||
const html = lines.map(l => `<div>${l}</div>`).join('');
|
||||
const html = lines.map(l => `<div>${escapeHtml(l)}</div>`).join('');
|
||||
|
||||
return { text, html };
|
||||
}
|
||||
|
||||
@@ -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