security hardening
This commit is contained in:
@@ -95,4 +95,20 @@ function enqueueMove(channel, categoryId) {
|
||||
return channel.setParent(categoryId, { lockPermissions: true });
|
||||
}
|
||||
|
||||
module.exports = { enqueueRename, enqueueMove };
|
||||
// Per-channel promise chain for send ordering and to prevent interleaving.
|
||||
const sendChains = new Map();
|
||||
|
||||
function enqueueSend(channel, ...args) {
|
||||
if (!channel || typeof channel.send !== 'function') {
|
||||
return Promise.reject(new Error('enqueueSend: invalid channel'));
|
||||
}
|
||||
const prev = sendChains.get(channel.id) || Promise.resolve();
|
||||
const next = prev.catch(() => {}).then(() => channel.send(...args));
|
||||
sendChains.set(channel.id, next);
|
||||
next.catch(() => {}).finally(() => {
|
||||
if (sendChains.get(channel.id) === next) sendChains.delete(channel.id);
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
module.exports = { enqueueRename, enqueueMove, enqueueSend };
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
const { EmbedBuilder } = require('discord.js');
|
||||
const { CONFIG, parseThresholdString } = require('../config');
|
||||
const { shouldFireCooldownEscalating, clearEscalating } = require('./patternStore');
|
||||
const { enqueueSend } = require('./channelQueue');
|
||||
|
||||
// channelId → { lastStaffMessageAt, unrespondedCount, lastAlertAt }
|
||||
const chatState = new Map();
|
||||
@@ -64,7 +65,7 @@ async function runChatAlertChecks(client) {
|
||||
try {
|
||||
const alertChan = await client.channels.fetch(alertChannelId);
|
||||
const content = CONFIG.SURGE_ROLE_ID ? `<@&${CONFIG.SURGE_ROLE_ID}>` : undefined;
|
||||
if (alertChan) await alertChan.send({ content, embeds: [embed] });
|
||||
if (alertChan) await enqueueSend(alertChan, { content, embeds: [embed] });
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
@@ -82,7 +83,7 @@ async function runChatAlertChecks(client) {
|
||||
try {
|
||||
const alertChan = await client.channels.fetch(alertChannelId);
|
||||
const content = CONFIG.SURGE_ROLE_ID ? `<@&${CONFIG.SURGE_ROLE_ID}>` : undefined;
|
||||
if (alertChan) await alertChan.send({ content, embeds: [embed] });
|
||||
if (alertChan) await enqueueSend(alertChan, { content, embeds: [embed] });
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,65 +15,6 @@ function getGmailClient() {
|
||||
return google.gmail({ version: 'v1', auth });
|
||||
}
|
||||
|
||||
async function sendGmailReply(
|
||||
threadId,
|
||||
replyText,
|
||||
recipientEmail,
|
||||
subject,
|
||||
discordUser,
|
||||
messageId
|
||||
) {
|
||||
const gmail = getGmailClient();
|
||||
|
||||
const utf8Subject = `=?utf-8?B?${Buffer.from(
|
||||
`Re: ${subject}`
|
||||
).toString('base64')}?=`;
|
||||
const safeUser = escapeHtml(discordUser);
|
||||
const safeReply = escapeHtml(replyText).replace(/\n/g, '<br>');
|
||||
const safeLogoUrl = escapeHtml(CONFIG.LOGO_URL || '');
|
||||
const safeSignature = escapeHtml(CONFIG.SIGNATURE || '').replace(/\n/g, '<br>');
|
||||
const htmlBody = `
|
||||
<div style="font-family: sans-serif; font-size: 14px; color: #333;">
|
||||
<p><strong>From:</strong> ${safeUser} on Discord</p>
|
||||
<p>${safeReply}</p>
|
||||
<hr style="border:none; border-top:1px solid #ddd; margin:20px 0;">
|
||||
<table border="0" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="padding-right: 12px;">
|
||||
${safeLogoUrl ? `<img src="${safeLogoUrl}" width="65">` : ''}
|
||||
</td>
|
||||
<td style="border-left: 1px solid #ddd; padding-left: 12px;">
|
||||
<p style="margin: 0; font-weight: bold;">${safeUser}</p>
|
||||
<div style="color: #666; font-size: 12px;">${safeSignature}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>`;
|
||||
|
||||
const headers = [
|
||||
`From: ${CONFIG.MY_EMAIL}`,
|
||||
`To: ${recipientEmail}`,
|
||||
`Subject: ${utf8Subject}`,
|
||||
messageId ? `In-Reply-To: ${messageId}` : '',
|
||||
messageId ? `References: ${messageId}` : '',
|
||||
'MIME-Version: 1.0',
|
||||
'Content-Type: text/html; charset="UTF-8"',
|
||||
'',
|
||||
htmlBody
|
||||
].filter(Boolean);
|
||||
|
||||
const raw = Buffer.from(headers.join('\r\n'))
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
|
||||
await gmail.users.messages.send({
|
||||
userId: 'me',
|
||||
requestBody: { raw, threadId }
|
||||
});
|
||||
}
|
||||
|
||||
async function sendTicketClosedEmail(ticket, discordDisplayName) {
|
||||
try {
|
||||
const gmail = getGmailClient();
|
||||
@@ -105,13 +46,15 @@ async function sendTicketClosedEmail(ticket, discordDisplayName) {
|
||||
finalSubject
|
||||
).toString('base64')}?=`;
|
||||
|
||||
const serverDisplayName = escapeHtml(discordDisplayName || 'Support');
|
||||
const serverDisplayName = escapeHtml(discordDisplayName || CONFIG.SUPPORT_NAME || 'Support');
|
||||
const safeLogoUrl = escapeHtml(CONFIG.LOGO_URL || '');
|
||||
const safeSignature = escapeHtml(CONFIG.SIGNATURE || '').replace(/\n/g, '<br>');
|
||||
const safeCloseMessage = escapeHtml(CONFIG.TICKET_CLOSE_MESSAGE || '').replace(/\n/g, '<br>');
|
||||
const safeCloseSignature = escapeHtml(CONFIG.TICKET_CLOSE_SIGNATURE || CONFIG.EMAIL_SIGNATURE || '').replace(/\n/g, '<br>');
|
||||
const htmlBody = `
|
||||
<div style="font-family: sans-serif; font-size: 14px; color: #333;">
|
||||
<p><strong>From:</strong> ${serverDisplayName} on Discord</p>
|
||||
<p><strong>Message:</</strong></p>
|
||||
<p><strong>Message:</strong></p>
|
||||
<p>${safeCloseMessage}</p>
|
||||
<p style="margin-top: 16px;">${safeCloseSignature}</p>
|
||||
<hr style="border:none; border-top:1px solid #ddd; margin:20px 0;">
|
||||
@@ -202,6 +145,9 @@ async function sendTicketNotificationEmail(ticket, subjectLine, messageBody, fro
|
||||
}
|
||||
|
||||
const safeSignature = escapeHtml(CONFIG.SIGNATURE || '').replace(/\n/g, '<br>');
|
||||
const serverDisplayName = label;
|
||||
const safeCloseMessage = safeBody;
|
||||
const safeCloseSignature = escapeHtml(CONFIG.TICKET_CLOSE_SIGNATURE || CONFIG.EMAIL_SIGNATURE || '').replace(/\n/g, '<br>');
|
||||
const htmlBody = `
|
||||
<div style="font-family: sans-serif; font-size: 14px; color: #333;">
|
||||
<p><strong>From:</strong> ${serverDisplayName} on Discord</p>
|
||||
|
||||
@@ -6,6 +6,7 @@ const { EmbedBuilder } = require('discord.js');
|
||||
const { CONFIG, parseThresholdString } = require('../config');
|
||||
const { mongoose } = require('../db-connection');
|
||||
const { getAll, get, shouldFireThreshold, onWeeklyReset } = require('./patternStore');
|
||||
const { enqueueSend } = require('./channelQueue');
|
||||
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
|
||||
@@ -28,7 +29,7 @@ async function postPattern(client, channelConfigKey, embed) {
|
||||
if (!channelId || !client) return;
|
||||
try {
|
||||
const channel = await client.channels.fetch(channelId);
|
||||
if (channel) await channel.send({ embeds: [embed] });
|
||||
if (channel) await enqueueSend(channel, { embeds: [embed] });
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
|
||||
@@ -128,9 +128,10 @@ function shouldFireCooldownEscalating(key, thresholdsMs) {
|
||||
let state = escalatingCooldowns.get(key);
|
||||
|
||||
if (!state) {
|
||||
state = { startedAtMs: now, lastFireAtMs: null, fireCount: 0 };
|
||||
state = { startedAtMs: now, lastFireAtMs: null, fireCount: 0, lastUsed: now };
|
||||
escalatingCooldowns.set(key, state);
|
||||
}
|
||||
state.lastUsed = now;
|
||||
|
||||
const nextThreshold = sortedThresholds[state.fireCount];
|
||||
if (typeof nextThreshold !== 'number') return null;
|
||||
@@ -147,6 +148,19 @@ function clearEscalating(key) {
|
||||
escalatingCooldowns.delete(key);
|
||||
}
|
||||
|
||||
const ESCALATING_COOLDOWN_TTL_MS = 48 * 60 * 60 * 1000;
|
||||
const ESCALATING_CLEANUP_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
||||
|
||||
function cleanupStaleEscalatingCooldowns() {
|
||||
const cutoff = Date.now() - ESCALATING_COOLDOWN_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?.();
|
||||
|
||||
function scheduleDailyReset() {
|
||||
setTimeout(() => {
|
||||
store.today = new Map();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const { CONFIG } = require('../config');
|
||||
const { enqueueSend } = require('./channelQueue');
|
||||
|
||||
/**
|
||||
* Create a staff tracking channel for a ticket.
|
||||
@@ -33,7 +34,7 @@ async function createStaffChannel(guild, ticket, claimerId, channelName) {
|
||||
.setFooter({ text: `Claimed by ${ticket.claimedBy || 'Unknown'}` })
|
||||
.setTimestamp();
|
||||
|
||||
const pinMsg = await staffChan.send({ embeds: [embed] });
|
||||
const pinMsg = await enqueueSend(staffChan, { embeds: [embed] });
|
||||
await pinMsg.pin().catch(() => {});
|
||||
|
||||
return staffChan;
|
||||
@@ -50,7 +51,7 @@ async function pingStaffChannel(staffChannel, claimerId, originalMessage) {
|
||||
if (!staffChannel) return;
|
||||
try {
|
||||
const jumpLink = `https://discord.com/channels/${originalMessage.guild.id}/${originalMessage.channel.id}/${originalMessage.id}`;
|
||||
await staffChannel.send(
|
||||
await enqueueSend(staffChannel,
|
||||
`<@${claimerId}> Customer replied in ticket:\n> ${originalMessage.content.slice(0, 500)}\n[Jump to message](${jumpLink})`
|
||||
);
|
||||
} catch (e) {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
const { mongoose } = require('../db-connection');
|
||||
const { CONFIG, parseThresholdString } = require('../config');
|
||||
const { increment } = require('./patternStore');
|
||||
const { enqueueSend } = require('./channelQueue');
|
||||
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
const StaffNotification = mongoose.model('StaffNotification');
|
||||
@@ -39,7 +40,8 @@ async function notifyStaffOfReply(guild, ticket, message) {
|
||||
|
||||
const jumpLink = `https://discord.com/channels/${guild.id}/${message.channel.id}/${message.id}`;
|
||||
const snippet = message.content?.slice(0, 300) || '(no text)';
|
||||
await notifChannel.send(
|
||||
await enqueueSend(
|
||||
notifChannel,
|
||||
`New reply in **${message.channel.name}** from ${message.author.tag}:\n> ${snippet}\n[Jump to message](${jumpLink})`
|
||||
);
|
||||
|
||||
@@ -82,7 +84,7 @@ async function notifyAllStaffUnclaimed(client) {
|
||||
for (const ticket of unclaimedTickets) {
|
||||
const ageMs = now - new Date(ticket.createdAt).getTime();
|
||||
const ageHours = ageMs / (60 * 60 * 1000);
|
||||
const alreadySent = ticket.unclaimedReminderssent || [];
|
||||
const alreadySent = ticket.unclaimedRemindersSent || [];
|
||||
|
||||
// Find thresholds crossed but not yet sent
|
||||
const crossedNew = sorted.filter(t => ageHours >= t && !alreadySent.includes(t));
|
||||
@@ -100,7 +102,7 @@ async function notifyAllStaffUnclaimed(client) {
|
||||
for (const rec of staffRecords) {
|
||||
const chan = await guild.channels.fetch(rec.channelId).catch(() => null);
|
||||
if (chan) {
|
||||
await chan.send(alertMsg).catch(e => console.error('Unclaimed notify send:', e));
|
||||
await enqueueSend(chan, alertMsg).catch(e => console.error('Unclaimed notify send:', e));
|
||||
increment('staff_stale_pings', rec.userId, 'today');
|
||||
increment('staff_stale_pings', rec.userId, 'week');
|
||||
}
|
||||
@@ -108,7 +110,7 @@ async function notifyAllStaffUnclaimed(client) {
|
||||
|
||||
await Ticket.updateOne(
|
||||
{ gmailThreadId: ticket.gmailThreadId },
|
||||
{ $addToSet: { unclaimedReminderssent: highest } }
|
||||
{ $addToSet: { unclaimedRemindersSent: highest } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* Notes:
|
||||
* - The bot requires CREATE_PRIVATE_THREADS and SEND_MESSAGES_IN_THREADS
|
||||
* permissions on every ticket category.
|
||||
* - Private threads (type: 12) require the server to have Community features
|
||||
* - Private threads (ChannelType.PrivateThread) require the server to have Community features
|
||||
* OR the channel to be in a server with Boost level that unlocks private
|
||||
* threads. If thread creation fails with code 50024 or 160004, a warning
|
||||
* is logged via logWarn.
|
||||
@@ -15,6 +15,7 @@
|
||||
* servers. The 300ms delay between adds avoids the thread member add rate
|
||||
* limit (approximately 5/second).
|
||||
*/
|
||||
const { ChannelType } = require('discord.js');
|
||||
const { CONFIG } = require('../config');
|
||||
const { logError, logWarn } = require('./debugLog');
|
||||
|
||||
@@ -32,7 +33,7 @@ async function createStaffThread(channel, client) {
|
||||
|
||||
const thread = await channel.threads.create({
|
||||
name: threadName,
|
||||
type: 12, // ChannelType.PrivateThread
|
||||
type: ChannelType.PrivateThread,
|
||||
invitable: false,
|
||||
reason: 'Staff discussion thread for ticket'
|
||||
});
|
||||
@@ -84,7 +85,7 @@ async function addMemberToStaffThread(channel, memberId) {
|
||||
try {
|
||||
const threads = await channel.threads.fetchActive();
|
||||
const staffThread = threads.threads.find(t =>
|
||||
t.name === CONFIG.STAFF_THREAD_NAME && t.type === 12
|
||||
t.name === CONFIG.STAFF_THREAD_NAME && t.type === ChannelType.PrivateThread
|
||||
);
|
||||
if (!staffThread) return;
|
||||
await staffThread.members.add(memberId);
|
||||
|
||||
@@ -7,6 +7,7 @@ const { CONFIG, parseThresholdString } = require('../config');
|
||||
const { mongoose } = require('../db-connection');
|
||||
const { shouldFireCooldownEscalating, clearEscalating, isStaffRecentlyActive } = require('./patternStore');
|
||||
const { getStaffAvailability, isAnyStaffAvailable } = require('./staffPresence');
|
||||
const { enqueueSend } = require('./channelQueue');
|
||||
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
|
||||
@@ -37,7 +38,7 @@ async function pingStaff(client, message, embedFields) {
|
||||
})));
|
||||
}
|
||||
const content = CONFIG.SURGE_ROLE_ID ? `<@&${CONFIG.SURGE_ROLE_ID}>` : undefined;
|
||||
await channel.send({ content, embeds: [embed] });
|
||||
await enqueueSend(channel, { content, embeds: [embed] });
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +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 Ticket = mongoose.model('Ticket');
|
||||
const TicketCounter = mongoose.model('TicketCounter');
|
||||
@@ -89,48 +90,51 @@ function makeTicketName(state, ticket, creatorNickname, claimerEmoji) {
|
||||
|
||||
async function canRename(ticket) {
|
||||
const now = Date.now();
|
||||
const fresh = await Ticket.findOne({ gmailThreadId: ticket.gmailThreadId })
|
||||
.select('renameCount renameWindowStart')
|
||||
.lean();
|
||||
if (!fresh) {
|
||||
return { ok: false, remaining: 0, waitMs: RENAME_WINDOW_MS };
|
||||
const windowCutoff = new Date(now - RENAME_WINDOW_MS);
|
||||
|
||||
// Atomic: reset the window if the stored start is older than the cutoff; count = 1.
|
||||
const resetDoc = await Ticket.findOneAndUpdate(
|
||||
{
|
||||
gmailThreadId: ticket.gmailThreadId,
|
||||
$or: [
|
||||
{ renameWindowStart: { $lt: windowCutoff } },
|
||||
{ renameWindowStart: null },
|
||||
{ renameWindowStart: { $exists: false } }
|
||||
]
|
||||
},
|
||||
{ $set: { renameWindowStart: new Date(now), renameCount: 1 } },
|
||||
{ new: true, projection: { renameCount: 1, renameWindowStart: 1 } }
|
||||
).lean();
|
||||
|
||||
if (resetDoc) {
|
||||
ticket.renameWindowStart = resetDoc.renameWindowStart;
|
||||
ticket.renameCount = resetDoc.renameCount;
|
||||
return { ok: true, remaining: RENAME_LIMIT - resetDoc.renameCount, waitMs: 0 };
|
||||
}
|
||||
|
||||
const windowStart = (fresh.renameWindowStart && new Date(fresh.renameWindowStart).getTime()) || 0;
|
||||
const count = fresh.renameCount || 0;
|
||||
|
||||
if (now - windowStart >= RENAME_WINDOW_MS) {
|
||||
await Ticket.updateOne(
|
||||
{ gmailThreadId: ticket.gmailThreadId },
|
||||
{ $set: { renameWindowStart: new Date(now), renameCount: 0 } }
|
||||
);
|
||||
ticket.renameWindowStart = new Date(now);
|
||||
ticket.renameCount = 0;
|
||||
return { ok: true, remaining: RENAME_LIMIT, waitMs: 0 };
|
||||
}
|
||||
|
||||
if (count >= RENAME_LIMIT) {
|
||||
const waitMs = RENAME_WINDOW_MS - (now - windowStart);
|
||||
return { ok: false, remaining: 0, waitMs };
|
||||
}
|
||||
|
||||
const updated = await Ticket.findOneAndUpdate(
|
||||
{ gmailThreadId: ticket.gmailThreadId },
|
||||
// Atomic: within window, only increment if count < limit.
|
||||
const incDoc = await Ticket.findOneAndUpdate(
|
||||
{
|
||||
gmailThreadId: ticket.gmailThreadId,
|
||||
renameCount: { $lt: RENAME_LIMIT }
|
||||
},
|
||||
{ $inc: { renameCount: 1 } },
|
||||
{ returnDocument: 'after' }
|
||||
)
|
||||
.select('renameCount renameWindowStart')
|
||||
.lean();
|
||||
{ new: true, projection: { renameCount: 1, renameWindowStart: 1 } }
|
||||
).lean();
|
||||
|
||||
if (!updated) {
|
||||
const waitMs = RENAME_WINDOW_MS - (now - windowStart);
|
||||
return { ok: false, remaining: 0, waitMs };
|
||||
if (incDoc) {
|
||||
ticket.renameWindowStart = incDoc.renameWindowStart;
|
||||
ticket.renameCount = incDoc.renameCount;
|
||||
return { ok: true, remaining: RENAME_LIMIT - incDoc.renameCount, waitMs: 0 };
|
||||
}
|
||||
|
||||
const newCount = updated.renameCount || 0;
|
||||
ticket.renameCount = newCount;
|
||||
ticket.renameWindowStart = updated.renameWindowStart;
|
||||
return { ok: true, remaining: RENAME_LIMIT - newCount, waitMs: 0 };
|
||||
// At limit — read the window start to compute waitMs.
|
||||
const fresh = await Ticket.findOne({ gmailThreadId: ticket.gmailThreadId })
|
||||
.select('renameWindowStart')
|
||||
.lean();
|
||||
const windowStart = (fresh?.renameWindowStart && new Date(fresh.renameWindowStart).getTime()) || now;
|
||||
const waitMs = Math.max(0, RENAME_WINDOW_MS - (now - windowStart));
|
||||
return { ok: false, remaining: 0, waitMs };
|
||||
}
|
||||
|
||||
function minutesFromMs(ms) {
|
||||
@@ -487,7 +491,7 @@ async function checkAutoClose(client, sendTicketClosedEmail) {
|
||||
|
||||
const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null);
|
||||
if (channel) {
|
||||
await channel.send(CONFIG.DISCORD_AUTO_CLOSE_MESSAGE);
|
||||
await enqueueSend(channel, CONFIG.DISCORD_AUTO_CLOSE_MESSAGE);
|
||||
|
||||
await withRetry(() => Ticket.updateOne(
|
||||
{ gmailThreadId: ticket.gmailThreadId },
|
||||
@@ -531,7 +535,7 @@ async function checkReminders(client) {
|
||||
const message = CONFIG.REMINDER_MESSAGE
|
||||
.replace(/\{hours\}/g, String(CONFIG.REMINDER_AFTER_HOURS))
|
||||
.replace(/\{ping\}/g, ping);
|
||||
await channel.send(message);
|
||||
await enqueueSend(channel, message);
|
||||
|
||||
await withRetry(() => Ticket.updateOne(
|
||||
{ gmailThreadId: ticket.gmailThreadId },
|
||||
@@ -570,7 +574,7 @@ async function checkAutoUnclaim(client) {
|
||||
{ $set: { claimedBy: null } }
|
||||
));
|
||||
|
||||
await channel.send(
|
||||
await enqueueSend(channel,
|
||||
`This ticket has been auto-unclaimed due to inactivity (${CONFIG.AUTO_UNCLAIM_AFTER_HOURS} hours).`
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user