huge changes
This commit is contained in:
@@ -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 = [];
|
||||
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
8
handlers/pendingCloses.js
Normal file
8
handlers/pendingCloses.js
Normal 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 };
|
||||
Reference in New Issue
Block a user