huge changes

This commit is contained in:
indifferentketchup
2026-04-07 01:43:06 -05:00
parent ca63ecbcfd
commit 69c247ed1b
37 changed files with 3468 additions and 169 deletions

View File

@@ -5,6 +5,7 @@
const { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } = require('discord.js');
const { CONFIG } = require('../config');
const { mongoose } = require('../db-connection');
const { logSecurity } = require('../services/debugLog');
const User = mongoose.model('User');
@@ -98,6 +99,11 @@ async function handleAccountInfoCommand(interaction) {
});
}
const identifier = subcommand === 'email'
? interaction.options.getString('email')
: interaction.options.getUser('user')?.tag || 'unknown';
logSecurity('Account lookup', interaction.user, `lookup: ${subcommand}${identifier}`, null, 0x0099ff).catch(() => {});
const embed = buildAccountInfoEmbed(user, interaction.user.tag);
const components = [];

View File

@@ -19,10 +19,13 @@ const { CONFIG } = require('../config');
const { canRename, makeTicketName, resolveCreatorNickname, minutesFromMs, getOrCreateTicketCategory, cleanupEmptyOverflowCategory, createDiscordTicketAsThread, checkTicketCreationRateLimit, getSenderLocal, toDiscordSafeName } = require('../services/tickets');
const { sendTicketClosedEmail } = require('../services/gmail');
const { getTicketActionRow } = require('../utils/ticketComponents');
const { sanitizeEmbedText, truncateEmbedDescription, truncateEmbedField, enforceEmbedLimit } = require('../utils');
const { setEmailRouting } = require('../services/guildSettings');
const { enqueueRename } = require('../services/channelQueue');
const { runEscalation, runDeescalation } = require('./commands');
const { trackInteraction, trackError } = require('./analytics');
const { pendingCloses } = require('./pendingCloses');
const { increment } = require('../services/patternStore');
const Ticket = mongoose.model('Ticket');
const Transcript = mongoose.model('Transcript');
@@ -131,7 +134,25 @@ async function handleButton(interaction) {
}
if (interaction.customId === 'confirm_close') {
return handleConfirmClose(interaction, ticket);
const timerSeconds = CONFIG.FORCE_CLOSE_TIMER;
if (pendingCloses.has(interaction.channel.id)) {
return interaction.reply({ content: 'A close is already pending for this ticket.', ephemeral: true });
}
await interaction.update({ content: `Closing ticket in ${timerSeconds} seconds. Use \`/cancel-close\` to abort.`, components: [] });
const timerId = setTimeout(async () => {
pendingCloses.delete(interaction.channel.id);
const freshTicket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
if (!freshTicket || freshTicket.status === 'closed') return;
const { logTicketEvent } = require('../services/debugLog');
logTicketEvent('Force-close timer fired', [
{ name: 'Ticket', value: interaction.channel.name || interaction.channel.id },
{ name: 'Set by', value: interaction.user.tag },
{ name: 'Duration', value: `${timerSeconds}s` }
]).catch(() => {});
await handleConfirmClose(interaction, freshTicket);
}, timerSeconds * 1000);
pendingCloses.set(interaction.channel.id, { timeout: timerId, userId: interaction.user.id, username: interaction.user.tag });
return;
}
if (interaction.customId === 'cancel_close') {
@@ -294,6 +315,8 @@ async function handleClaim(interaction, ticket) {
}
if (isClaimed && !isClaimedByMe && !CONFIG.ALLOW_CLAIM_OVERWRITE) {
const { logSecurity } = require('../services/debugLog');
logSecurity('Unauthorized button attempt', interaction.user, interaction.customId).catch(() => {});
return interaction.reply({
content: `This ticket is already claimed by **${freshTicket.claimedBy}**.`,
ephemeral: true
@@ -307,6 +330,8 @@ async function handleClaim(interaction, ticket) {
);
freshTicket.claimedBy = claimerLabel;
freshTicket.claimerId = interaction.user.id;
increment('staff_claims', interaction.user.id, 'today');
increment('staff_claims', interaction.user.id, 'week');
// Resolve claimerEmoji from STAFF_EMOJIS map (fallback to CLAIMER_EMOJI_FALLBACK)
const claimerEmoji = CONFIG.STAFF_EMOJIS.get(interaction.user.id) || CONFIG.CLAIMER_EMOJI_FALLBACK;
@@ -358,6 +383,8 @@ async function handleClaim(interaction, ticket) {
.setColor(CONFIG.EMBED_COLOR_CLAIMED)
.setFooter({ text: `Claimed by ${claimerLabel}` });
await interaction.followUp({ embeds: [claimEmbed] });
const { addMemberToStaffThread } = require('../services/staffThread');
await addMemberToStaffThread(interaction.channel, interaction.user.id).catch(() => {});
} else {
// Unclaim
await Ticket.updateOne(
@@ -415,6 +442,10 @@ async function handleClaim(interaction, ticket) {
// --- CONFIRM CLOSE ---
async function handleConfirmClose(interaction, ticket) {
const closedAt = new Date();
increment('staff_closes', interaction.user.id, 'today');
if (!ticket.ticketTag) {
increment('untagged_closes', 'total', 'today');
}
try {
await interaction.update({ content: 'Archiving and closing...', components: [] });
} catch {
@@ -669,35 +700,64 @@ async function handleTicketModal(interaction) {
const displayName = interaction.member?.displayName || interaction.user.username;
// Welcome embed (dark grey #1e2124)
const welcomeEmbed = new EmbedBuilder()
.setDescription(CONFIG.TICKET_WELCOME_MESSAGE)
.setColor(CONFIG.EMBED_COLOR_INFO)
.setFooter({ text: 'Indifferent Broccoli Tickets' });
// Ticket details embed (dark) short labels, trimmed description
const descTrimmed = description.length > 500 ? description.slice(0, 497) + '…' : description;
const welcomeEmbed = new EmbedBuilder()
.setTitle("We got your ticket.")
.setDescription("We'll be with you as soon as possible.")
.setColor(5763719)
.setThumbnail("https://indifferentbroccoli.com/img/broccoli_shadow_square.png")
.setFooter({ text: "indifferent broccoli tickets (:|)", iconURL: "https://i.ibb.co/sJdytfFM/Untitled-design-6.png" });
const infoEmbed = new EmbedBuilder()
.setColor(CONFIG.EMBED_COLOR_INFO)
.setColor(5763719)
.setDescription(truncateEmbedDescription(
`**Account Email:**\n\`\`\`\n${sanitizeEmbedText(email)}\n\`\`\`\n` +
`**Game:**\n\`\`\`\n${sanitizeEmbedText(game) || "Not specified"}\n\`\`\`\n` +
`**What do you need help with?**\n\`\`\`\n${sanitizeEmbedText(descTrimmed)}\n\`\`\``
));
const resourcesEmbed = new EmbedBuilder()
.setTitle("We're ~~happy~~ indifferent to help. :indifferentbroccoli:")
.setDescription("Please feel free to add any additional information to the ticket, including recent changes to the server, if any.")
.setColor(5763719)
.addFields(
{ name: 'Email', value: email, inline: true },
{ name: 'Game', value: game || 'Not specified', inline: true },
{ name: 'Description', value: descTrimmed, inline: false }
{ name: "Check out our wiki for guides:", value: "[Indifferent Broccolipedia](https://wiki.indifferentbroccoli.com)", inline: false }
)
.setTimestamp();
.setFooter({ text: "indifferent broccoli tickets (:|)", iconURL: "https://i.ibb.co/sJdytfFM/Untitled-design-6.png" });
const actionRow = getTicketActionRow({ escalationTier: 0 });
const welcomeMsg = await channel.send({
content: `Hey There ${interaction.user} 🥦`,
embeds: [welcomeEmbed, infoEmbed],
components: [actionRow]
});
enforceEmbedLimit([welcomeEmbed, infoEmbed, resourcesEmbed]);
try {
const welcomeMsg = await channel.send({
content: `Hey There ${interaction.user} 🥦`,
embeds: [welcomeEmbed, infoEmbed, resourcesEmbed],
components: [actionRow]
});
await Ticket.updateOne(
{ discordThreadId: channel.id },
{ $set: { welcomeMessageId: welcomeMsg.id } }
);
await Ticket.updateOne(
{ discordThreadId: channel.id },
{ $set: { welcomeMessageId: welcomeMsg.id } }
);
} catch (err) {
console.error('welcomeMessageId-save', err);
}
const { createStaffThread } = require('../services/staffThread');
await createStaffThread(channel, interaction.client).catch(() => {});
if (CONFIG.PIN_INITIAL_MESSAGE_ENABLED && welcomeMsg) {
const { pinMessage } = require('../services/pinMessage');
await pinMessage(welcomeMsg, interaction.client).catch(() => {});
}
increment('user_tickets', interaction.user.id, 'today');
increment('user_tickets', interaction.user.id, 'week');
if (game) {
increment('game_tickets', game, 'today');
increment('game_tickets', game, 'week');
}
await interaction.deleteReply().catch(() => {});

View File

@@ -20,8 +20,11 @@ const { getEmailRouting } = require('../services/guildSettings');
const { enqueueRename, enqueueMove } = require('../services/channelQueue');
const { setNotifyDm } = require('../services/staffSettings');
const { trackInteraction, trackError, getAnalyticsSummary } = require('./analytics');
const { logTicketEvent, logSecurity } = require('../services/debugLog');
const { handleAccountInfoCommand } = require('./accountinfo');
const { handleSetupCommand } = require('./setup');
const { pendingCloses } = require('./pendingCloses');
const { increment } = require('../services/patternStore');
const Ticket = mongoose.model('Ticket');
const Tag = mongoose.model('Tag');
@@ -55,6 +58,7 @@ async function requireStaffRole(interaction) {
content: `This command is only available to the support team (${roleMention}).`,
ephemeral: true
});
logSecurity('Unauthorized command attempt', interaction.user, interaction.commandName).catch(() => {});
return true;
}
@@ -75,6 +79,12 @@ async function runEscalation(interaction, ticket, nextTier, reason) {
ticket.escalated = true;
ticket.escalationTier = nextTier;
ticket.claimedBy = null;
increment('escalations', ticket.game || 'unknown', 'today');
increment('escalations', ticket.game || 'unknown', 'week');
increment('user_escalations', ticket.senderEmail, 'week');
increment('staff_escalations', interaction.user.id, 'today');
increment('staff_escalations', interaction.user.id, 'week');
if (ticket.game) increment(`staff_game_escalations:${interaction.user.id}`, ticket.game, 'week');
const creatorNickname = await resolveCreatorNickname(interaction.guild, ticket);
const renameInfo = await canRename(ticket);
@@ -124,12 +134,17 @@ async function runEscalation(interaction, ticket, nextTier, reason) {
.setFooter({ text: `Escalated by ${interaction.member?.displayName || interaction.user.username}` });
const updatedTicketForRow = { ...ticket, escalationTier: nextTier, escalated: true };
const escalationRow = getTicketActionRow(updatedTicketForRow);
await interaction.channel.send({
const escalationMsg = await interaction.channel.send({
content: null,
embeds: [escalatedEmbed],
components: [escalationRow]
});
if (CONFIG.PIN_ESCALATION_MESSAGE_ENABLED && escalationMsg) {
const { pinMessage } = require('../services/pinMessage');
await pinMessage(escalationMsg, interaction.client).catch(() => {});
}
if (!isDiscordTicket && ticket.gmailThreadId) {
try {
const emailBody = CONFIG.ESCALATION_MESSAGE.replace(/\{support_name\}/g, CONFIG.SUPPORT_NAME) + (reason ? `\n\nReason: ${reason}` : '');
@@ -144,12 +159,16 @@ async function runEscalation(interaction, ticket, nextTier, reason) {
}
}
if (nextTier === 2 && ticket.welcomeMessageId) {
try {
const welcomeMsg = await interaction.channel.messages.fetch(ticket.welcomeMessageId);
await welcomeMsg.edit({ components: [getTicketActionRow(updatedTicketForRow)] });
} catch (e) {
console.error('Failed to update welcome message after escalate:', e.message);
if (nextTier === 2) {
if (!ticket.welcomeMessageId) {
console.warn('welcomeMessageId is null/undefined; skipping welcome-message update for escalation');
} else {
try {
const welcomeMsg = await interaction.channel.messages.fetch(ticket.welcomeMessageId);
await welcomeMsg.edit({ components: [getTicketActionRow(updatedTicketForRow)] });
} catch (e) {
console.error('Failed to update welcome message after escalate:', e.message);
}
}
}
@@ -376,6 +395,7 @@ async function handleCommand(interaction) {
// /staffnotification (admin only)
if (interaction.commandName === 'staffnotification') {
if (interaction.user.id !== CONFIG.ADMIN_ID) {
logSecurity('Unauthorized command attempt', interaction.user, interaction.commandName).catch(() => {});
return interaction.reply({ content: 'This command is restricted to the bot admin.', ephemeral: true });
}
const member = interaction.options.getMember('member');
@@ -540,6 +560,79 @@ async function handleCommand(interaction) {
}
}
// /gmailpoll
// /staffthread
if (interaction.commandName === 'staffthread') {
const sub = interaction.options.getSubcommand();
if (sub === 'toggle') {
CONFIG.STAFF_THREAD_ENABLED = !CONFIG.STAFF_THREAD_ENABLED;
return interaction.reply({ content: `Staff threads are now **${CONFIG.STAFF_THREAD_ENABLED ? 'enabled' : 'disabled'}**.`, ephemeral: true });
}
if (sub === 'name') {
const name = interaction.options.getString('thread_name').slice(0, 100);
CONFIG.STAFF_THREAD_NAME = name;
return interaction.reply({ content: `Staff thread name set to **${name}**.`, ephemeral: true });
}
if (sub === 'autorole') {
const enabled = interaction.options.getBoolean('enabled');
CONFIG.STAFF_THREAD_AUTO_ADD_ROLE = enabled;
return interaction.reply({ content: `Auto-add role to staff thread is now **${enabled ? 'enabled' : 'disabled'}**.`, ephemeral: true });
}
return;
}
// /pinmessages
if (interaction.commandName === 'pinmessages') {
const sub = interaction.options.getSubcommand();
const enabled = interaction.options.getBoolean('enabled');
if (sub === 'initial') {
CONFIG.PIN_INITIAL_MESSAGE_ENABLED = enabled;
return interaction.reply({ content: `Auto-pin initial message is now **${enabled ? 'enabled' : 'disabled'}**.`, ephemeral: true });
}
if (sub === 'escalation') {
CONFIG.PIN_ESCALATION_MESSAGE_ENABLED = enabled;
return interaction.reply({ content: `Auto-pin escalation message is now **${enabled ? 'enabled' : 'disabled'}**.`, ephemeral: true });
}
if (sub === 'suppress') {
CONFIG.PIN_SUPPRESS_SYSTEM_MESSAGE = enabled;
return interaction.reply({ content: `Suppress pin system message is now **${enabled ? 'enabled' : 'disabled'}**.`, ephemeral: true });
}
return;
}
if (interaction.commandName === 'gmailpoll') {
const seconds = parseInt(interaction.options.getString('interval'), 10);
const { setGmailPollInterval } = require('../broccolini-discord');
setGmailPollInterval(seconds * 1000);
logTicketEvent('Gmail poll interval updated', [{ name: 'Interval', value: `${seconds}s` }, { name: 'Set by', value: interaction.user.tag }], interaction).catch(() => {});
return interaction.reply({ content: `Gmail poll interval set to ${seconds} seconds.`, ephemeral: true });
}
// /closetimer
if (interaction.commandName === 'closetimer') {
const seconds = parseInt(interaction.options.getString('seconds'), 10);
CONFIG.FORCE_CLOSE_TIMER = seconds;
logTicketEvent('Close timer updated', [{ name: 'Duration', value: `${seconds}s` }, { name: 'Set by', value: interaction.user.tag }], interaction).catch(() => {});
return interaction.reply({ content: `Force-close timer set to ${seconds} seconds.`, ephemeral: true });
}
// /cancel-close
if (interaction.commandName === 'cancel-close') {
const pending = pendingCloses.get(interaction.channel.id);
if (!pending) {
return interaction.reply({ content: 'No pending close for this channel.', ephemeral: true });
}
clearTimeout(pending.timeout);
const { logTicketEvent } = require('../services/debugLog');
logTicketEvent('Force-close cancelled', [
{ name: 'Ticket', value: interaction.channel.name || interaction.channel.id },
{ name: 'Cancelled by', value: interaction.user.tag },
{ name: 'Original setter', value: pending.username || 'Unknown' }
], interaction).catch(() => {});
pendingCloses.delete(interaction.channel.id);
return interaction.reply({ content: 'Close cancelled.', ephemeral: true });
}
// /force-close
if (interaction.commandName === 'force-close') {
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
@@ -547,71 +640,86 @@ async function handleCommand(interaction) {
return interaction.reply({ content: 'This channel is not linked to a ticket.', ephemeral: true });
}
try {
await Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $set: { status: 'closed' } }
);
if (pendingCloses.has(interaction.channel.id)) {
return interaction.reply({ content: 'A close is already pending for this ticket.', ephemeral: true });
}
await interaction.reply('Ticket force-closed. Archiving...');
const timerSeconds = CONFIG.FORCE_CLOSE_TIMER;
await interaction.reply(`Closing ticket in ${timerSeconds} seconds. Use \`/cancel-close\` to abort.`);
const channelRef = interaction.channel;
const clientRef = interaction.client;
const timerId = setTimeout(async () => {
pendingCloses.delete(channelRef.id);
const freshTicket = await Ticket.findOne({ discordThreadId: channelRef.id }).lean();
if (!freshTicket || freshTicket.status === 'closed') return;
try {
await interaction.channel.send(CONFIG.DISCORD_CLOSE_MESSAGE);
await Ticket.updateOne(
{ gmailThreadId: freshTicket.gmailThreadId },
{ $set: { status: 'closed' } }
);
const messages = await interaction.channel.messages.fetch({ limit: 100 });
const log =
`TRANSCRIPT: ${ticket.subject}\nUser: ${ticket.senderEmail}\n---\n` +
messages
.reverse()
.map(m => `[${m.createdAt.toLocaleString()}] ${m.author.tag}: ${m.cleanContent}`)
.join('\n');
await channelRef.send('Ticket force-closed. Archiving...');
const file = new AttachmentBuilder(Buffer.from(log), {
name: `transcript-${interaction.channel.name}.txt`
});
const transcriptChan = await interaction.client.channels
.fetch(CONFIG.TRANSCRIPT_CHAN)
.catch(() => null);
if (transcriptChan) {
const closedAt = new Date();
const openedStr = new Date(ticket.createdAt).toLocaleString('en-US', {
month: '2-digit', day: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: true, timeZoneName: 'short'
});
const closedStr = closedAt.toLocaleString('en-US', {
month: '2-digit', day: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: true, timeZoneName: 'short'
});
const transcriptContent = CONFIG.DISCORD_TRANSCRIPT_MESSAGE
.replace(/\{channel_name\}/g, interaction.channel.name)
.replace(/\{email\}/g, ticket.senderEmail || '')
.replace(/\{date_opened\}/g, openedStr)
.replace(/\{date_closed\}/g, closedStr)
+ `\n\nDate Opened: ${openedStr}\nDate Closed: ${closedStr}`;
await transcriptChan.send({
content: transcriptContent,
files: [file]
});
}
} catch (tErr) {
console.error('Transcript error (force-close):', tErr);
}
setTimeout(async () => {
try {
await interaction.channel.delete('Ticket force-closed');
} catch (e) {
console.error('Failed to delete channel:', e);
await channelRef.send(CONFIG.DISCORD_CLOSE_MESSAGE);
const messages = await channelRef.messages.fetch({ limit: 100 });
const log =
`TRANSCRIPT: ${freshTicket.subject}\nUser: ${freshTicket.senderEmail}\n---\n` +
messages
.reverse()
.map(m => `[${m.createdAt.toLocaleString()}] ${m.author.tag}: ${m.cleanContent}`)
.join('\n');
const file = new AttachmentBuilder(Buffer.from(log), {
name: `transcript-${channelRef.name}.txt`
});
const transcriptChan = await clientRef.channels
.fetch(CONFIG.TRANSCRIPT_CHAN)
.catch(() => null);
if (transcriptChan) {
const closedAt = new Date();
const openedStr = new Date(freshTicket.createdAt).toLocaleString('en-US', {
month: '2-digit', day: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: true, timeZoneName: 'short'
});
const closedStr = closedAt.toLocaleString('en-US', {
month: '2-digit', day: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: true, timeZoneName: 'short'
});
const transcriptContent = CONFIG.DISCORD_TRANSCRIPT_MESSAGE
.replace(/\{channel_name\}/g, channelRef.name)
.replace(/\{email\}/g, freshTicket.senderEmail || '')
.replace(/\{date_opened\}/g, openedStr)
.replace(/\{date_closed\}/g, closedStr)
+ `\n\nDate Opened: ${openedStr}\nDate Closed: ${closedStr}`;
await transcriptChan.send({
content: transcriptContent,
files: [file]
});
}
} catch (tErr) {
console.error('Transcript error (force-close):', tErr);
}
}, 5000);
} catch (err) {
console.error('Force close error:', err);
await interaction.reply({ content: 'Failed to close ticket.', ephemeral: true });
}
setTimeout(async () => {
try {
await channelRef.delete('Ticket force-closed');
} catch (e) {
console.error('Failed to delete channel:', e);
}
}, 5000);
} catch (err) {
console.error('Force close error:', err);
}
}, timerSeconds * 1000);
pendingCloses.set(channelRef.id, { timeout: timerId, userId: interaction.user.id, username: interaction.user.tag });
}
// /topic
@@ -649,6 +757,9 @@ async function handleCommand(interaction) {
const emoji = tagEntry ? tagEntry.emoji : '';
const channelMessage = `Your ticket has been categorized as ${emoji} **${tagEntry ? tagEntry.name : categoryValue}** ${emoji}.`;
await interaction.reply(channelMessage);
increment('tag_usage', categoryValue, 'today');
increment('tag_usage', categoryValue, 'week');
if (ticket.game) increment(`tag_game:${categoryValue}`, ticket.game, 'week');
} catch (err) {
trackError('tag-command', err, interaction);
await interaction.reply({ content: 'Failed to set ticket category.', ephemeral: true });
@@ -1189,16 +1300,20 @@ async function handleContextMenu(interaction) {
const row = getTicketActionRow({ escalationTier: 0 });
const welcomeMsg = await channel.send({
content: `<@&${CONFIG.ROLE_ID_TO_PING}>\nHey There ${message.author} 🥦`,
embeds: [welcomeEmbed, infoEmbed],
components: [row]
});
try {
const welcomeMsg = await channel.send({
content: `<@&${CONFIG.ROLE_ID_TO_PING}>\nHey There ${message.author} 🥦`,
embeds: [welcomeEmbed, infoEmbed],
components: [row]
});
await Ticket.updateOne(
{ discordThreadId: channel.id },
{ $set: { welcomeMessageId: welcomeMsg.id } }
);
await Ticket.updateOne(
{ discordThreadId: channel.id },
{ $set: { welcomeMessageId: welcomeMsg.id } }
);
} catch (err) {
console.error('welcomeMessageId-save', err);
}
await interaction.editReply(`✅ Ticket created: ${channel}`);
} catch (err) {

View File

@@ -44,16 +44,20 @@ async function handleDiscordReply(m) {
}
}
// Track whether last message is from staff or customer
const memberForCheck = await m.guild.members.fetch(m.author.id).catch(() => null);
const isStaffMember = memberForCheck && CONFIG.ROLE_ID_TO_PING && memberForCheck.roles.cache.has(CONFIG.ROLE_ID_TO_PING);
Ticket.updateOne(
{ discordThreadId: m.channel.id },
{ $set: { lastMessageAuthorIsStaff: !!isStaffMember, lastActivity: new Date() } }
).catch(() => {});
// Notify claiming staff if a non-staff user replied (works for both Discord and email tickets)
if (ticket.claimerId) {
if (ticket.claimerId && !isStaffMember) {
const guild = m.guild;
const member = await guild.members.fetch(m.author.id).catch(() => null);
const isStaff = member && CONFIG.ROLE_ID_TO_PING && member.roles.cache.has(CONFIG.ROLE_ID_TO_PING);
if (!isStaff) {
const freshTicket = await Ticket.findOne({ discordThreadId: m.channel.id }).lean();
if (freshTicket) {
await notifyStaffOfReply(guild, freshTicket, m).catch(e => console.error('notifyStaffOfReply:', e));
}
const freshTicket = await Ticket.findOne({ discordThreadId: m.channel.id }).lean();
if (freshTicket) {
await notifyStaffOfReply(guild, freshTicket, m).catch(e => console.error('notifyStaffOfReply:', e));
}
}

View File

@@ -0,0 +1,8 @@
/**
* Shared pending-close timer map.
* Keyed by channel.id → { timeout, userId, username }.
* Used by buttons.js (sets timers) and commands.js (cancel-close clears them).
*/
const pendingCloses = new Map();
module.exports = { pendingCloses };