Compare commits
3 Commits
840b6bfcf8
...
e3b3b8d48c
| Author | SHA1 | Date | |
|---|---|---|---|
| e3b3b8d48c | |||
| 3ac23466b2 | |||
| 83b6b4ae0c |
@@ -18,8 +18,8 @@ const CONFIG = {
|
|||||||
DISCORD_TICKET_CATEGORY_ID: process.env.DISCORD_TICKET_CATEGORY_ID || process.env.TICKET_CATEGORY_ID,
|
DISCORD_TICKET_CATEGORY_ID: process.env.DISCORD_TICKET_CATEGORY_ID || process.env.TICKET_CATEGORY_ID,
|
||||||
ROLE_ID_TO_PING: process.env.ROLE_ID_TO_PING,
|
ROLE_ID_TO_PING: process.env.ROLE_ID_TO_PING,
|
||||||
ROLE_TO_PING_ID: process.env.ROLE_ID_TO_PING || process.env.ROLE_TO_PING_ID,
|
ROLE_TO_PING_ID: process.env.ROLE_ID_TO_PING || process.env.ROLE_TO_PING_ID,
|
||||||
TRANSCRIPT_CHAN: process.env.TRANSCRIPT_CHANNEL_ID,
|
TRANSCRIPT_CHANNEL_ID: process.env.TRANSCRIPT_CHANNEL_ID,
|
||||||
LOG_CHAN: process.env.LOGGING_CHANNEL_ID,
|
LOGGING_CHANNEL_ID: process.env.LOGGING_CHANNEL_ID,
|
||||||
DEBUGGING_CHANNEL_ID: process.env.DEBUGGING_CHANNEL_ID || null,
|
DEBUGGING_CHANNEL_ID: process.env.DEBUGGING_CHANNEL_ID || null,
|
||||||
CLIENT_ID: process.env.DISCORD_APPLICATION_ID,
|
CLIENT_ID: process.env.DISCORD_APPLICATION_ID,
|
||||||
REFRESH_TOKEN: process.env.REFRESH_TOKEN,
|
REFRESH_TOKEN: process.env.REFRESH_TOKEN,
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ const {
|
|||||||
stripEmailQuotes,
|
stripEmailQuotes,
|
||||||
stripMobileFooter,
|
stripMobileFooter,
|
||||||
detectGame,
|
detectGame,
|
||||||
enforceEmbedLimit,
|
|
||||||
sanitizeEmbedText
|
sanitizeEmbedText
|
||||||
} = require('./utils');
|
} = require('./utils');
|
||||||
const { getGmailClient } = require('./services/gmail');
|
const { getGmailClient } = require('./services/gmail');
|
||||||
@@ -225,7 +224,6 @@ async function poll(client) {
|
|||||||
{ name: 'Subject', value: `\`\`\`\n${sanitizeEmbedText(subject) || 'No subject'}\n\`\`\``, inline: false }
|
{ name: 'Subject', value: `\`\`\`\n${sanitizeEmbedText(subject) || 'No subject'}\n\`\`\``, inline: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
enforceEmbedLimit([ticketInfoEmbed]);
|
|
||||||
const welcomeMsg = await enqueueSend(ticketChan, {
|
const welcomeMsg = await enqueueSend(ticketChan, {
|
||||||
content: `<@&${CONFIG.ROLE_ID_TO_PING}>`,
|
content: `<@&${CONFIG.ROLE_ID_TO_PING}>`,
|
||||||
embeds: [ticketInfoEmbed],
|
embeds: [ticketInfoEmbed],
|
||||||
@@ -251,7 +249,7 @@ async function poll(client) {
|
|||||||
|
|
||||||
if (transcriptRows.length > 0) {
|
if (transcriptRows.length > 0) {
|
||||||
const transcriptChan = await client.channels
|
const transcriptChan = await client.channels
|
||||||
.fetch(CONFIG.TRANSCRIPT_CHAN)
|
.fetch(CONFIG.TRANSCRIPT_CHANNEL_ID)
|
||||||
.catch(() => null);
|
.catch(() => null);
|
||||||
|
|
||||||
if (transcriptChan) {
|
if (transcriptChan) {
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Button interaction handlers – claim, close, priority, tag delete,
|
* Button interaction handlers and the ticket-creation modal submit.
|
||||||
* open-ticket panel button, and ticket_modal submission.
|
*
|
||||||
|
* The dispatcher pattern: handleButton splits buttons into two tables —
|
||||||
|
* FREE_BUTTON_HANDLERS for buttons that don't need a ticket (open-ticket
|
||||||
|
* panel, tag-delete cancel) and TICKET_BUTTON_HANDLERS for buttons fired
|
||||||
|
* inside a ticket channel. The dispatcher does one ticket lookup before
|
||||||
|
* delegating to a TICKET_BUTTON_HANDLERS entry. To find a button's
|
||||||
|
* implementation, search for handle<Name>Button (or handleTagDelete*).
|
||||||
*/
|
*/
|
||||||
const {
|
const {
|
||||||
ChannelType,
|
ChannelType,
|
||||||
@@ -19,27 +25,37 @@ const { CONFIG } = require('../config');
|
|||||||
const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, cleanupEmptyOverflowCategory, checkTicketCreationRateLimit, toDiscordSafeName } = require('../services/tickets');
|
const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, cleanupEmptyOverflowCategory, checkTicketCreationRateLimit, toDiscordSafeName } = require('../services/tickets');
|
||||||
const { sendTicketClosedEmail } = require('../services/gmail');
|
const { sendTicketClosedEmail } = require('../services/gmail');
|
||||||
const { getTicketActionRow } = require('../utils/ticketComponents');
|
const { getTicketActionRow } = require('../utils/ticketComponents');
|
||||||
const { sanitizeEmbedText, truncateEmbedDescription, enforceEmbedLimit } = require('../utils');
|
const { sanitizeEmbedText, truncateEmbedDescription } = require('../utils');
|
||||||
const { enqueueRename, enqueueSend } = require('../services/channelQueue');
|
const { enqueueRename, enqueueSend } = require('../services/channelQueue');
|
||||||
const { runEscalation, runDeescalation } = require('./commands');
|
const { runEscalation, runDeescalation } = require('./commands');
|
||||||
const { pendingCloses } = require('./pendingCloses');
|
const { pendingCloses } = require('./pendingCloses');
|
||||||
const { logError } = require('../services/debugLog');
|
const { addMemberToStaffThread, createStaffThread } = require('../services/staffThread');
|
||||||
|
const { pinMessage } = require('../services/pinMessage');
|
||||||
|
const { logError, logTicketEvent } = require('../services/debugLog');
|
||||||
|
const { findTicketForChannel, runDeferred } = require('./sharedHelpers');
|
||||||
|
|
||||||
const Ticket = mongoose.model('Ticket');
|
const Ticket = mongoose.model('Ticket');
|
||||||
const Transcript = mongoose.model('Transcript');
|
const Transcript = mongoose.model('Transcript');
|
||||||
const Tag = mongoose.model('Tag');
|
const Tag = mongoose.model('Tag');
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Free-standing button handlers (no ticket lookup)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main button/modal handler – called from interactionCreate.
|
* Open-ticket panel button (any of `open_ticket`, `open_ticket_thread`,
|
||||||
|
* `open_ticket_channel`). Shows the ticket-creation modal.
|
||||||
*/
|
*/
|
||||||
async function handleButton(interaction) {
|
async function handleOpenTicketModal(interaction) {
|
||||||
// --- "Open Ticket" panel buttons → show modal ---
|
|
||||||
if (interaction.customId === 'open_ticket' || interaction.customId === 'open_ticket_thread' || interaction.customId === 'open_ticket_channel') {
|
|
||||||
const modalCustomId = interaction.customId === 'open_ticket'
|
const modalCustomId = interaction.customId === 'open_ticket'
|
||||||
? 'ticket_modal'
|
? 'ticket_modal'
|
||||||
: interaction.customId === 'open_ticket_thread'
|
: interaction.customId === 'open_ticket_thread'
|
||||||
? 'ticket_modal_thread'
|
? 'ticket_modal_thread'
|
||||||
: 'ticket_modal_channel';
|
: 'ticket_modal_channel';
|
||||||
|
return interaction.showModal(buildOpenTicketModal(modalCustomId));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOpenTicketModal(modalCustomId) {
|
||||||
const modal = new ModalBuilder()
|
const modal = new ModalBuilder()
|
||||||
.setCustomId(modalCustomId)
|
.setCustomId(modalCustomId)
|
||||||
.setTitle('Please Enter Your Information');
|
.setTitle('Please Enter Your Information');
|
||||||
@@ -74,244 +90,42 @@ async function handleButton(interaction) {
|
|||||||
new ActionRowBuilder().addComponents(descriptionInput)
|
new ActionRowBuilder().addComponents(descriptionInput)
|
||||||
);
|
);
|
||||||
|
|
||||||
return await interaction.showModal(modal);
|
return modal;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Ticket-scoped buttons (need ticket lookup) ---
|
async function handleTagDeleteCancel(interaction) {
|
||||||
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
|
return interaction.update({ content: 'Tag deletion cancelled.', components: [] });
|
||||||
if (!ticket) {
|
|
||||||
return interaction.reply({
|
|
||||||
content: 'This channel is not linked to a ticket, or the ticket could not be found.',
|
|
||||||
ephemeral: true
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- CLAIM / UNCLAIM ---
|
async function handleTagDeleteConfirm(interaction) {
|
||||||
if (interaction.customId === 'claim_ticket') {
|
|
||||||
return handleClaim(interaction, ticket);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- CLOSE ---
|
|
||||||
if (interaction.customId === 'close_ticket') {
|
|
||||||
const isEmailTicket = ticket.gmailThreadId && !ticket.gmailThreadId.startsWith('discord-');
|
|
||||||
const confirmRow = new ActionRowBuilder();
|
|
||||||
if (isEmailTicket) {
|
|
||||||
confirmRow.addComponents(
|
|
||||||
new ButtonBuilder()
|
|
||||||
.setCustomId('confirm_close_with_email')
|
|
||||||
.setLabel('Confirm Close With Email')
|
|
||||||
.setStyle(ButtonStyle.Danger),
|
|
||||||
new ButtonBuilder()
|
|
||||||
.setCustomId('confirm_close_no_email')
|
|
||||||
.setLabel('Confirm Close Without Email')
|
|
||||||
.setStyle(ButtonStyle.Danger),
|
|
||||||
new ButtonBuilder()
|
|
||||||
.setCustomId('cancel_close')
|
|
||||||
.setLabel('Cancel')
|
|
||||||
.setStyle(ButtonStyle.Secondary)
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
confirmRow.addComponents(
|
|
||||||
new ButtonBuilder()
|
|
||||||
.setCustomId('confirm_close')
|
|
||||||
.setLabel('Confirm Close')
|
|
||||||
.setStyle(ButtonStyle.Danger),
|
|
||||||
new ButtonBuilder()
|
|
||||||
.setCustomId('cancel_close')
|
|
||||||
.setLabel('Cancel')
|
|
||||||
.setStyle(ButtonStyle.Secondary)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return interaction.reply({
|
|
||||||
content: 'Are you sure you want to close this ticket?',
|
|
||||||
components: [confirmRow]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
interaction.customId === 'confirm_close' ||
|
|
||||||
interaction.customId === 'confirm_close_with_email' ||
|
|
||||||
interaction.customId === 'confirm_close_no_email'
|
|
||||||
) {
|
|
||||||
const sendEmail = interaction.customId !== 'confirm_close_no_email';
|
|
||||||
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 });
|
|
||||||
}
|
|
||||||
const cancelRow = new ActionRowBuilder().addComponents(
|
|
||||||
new ButtonBuilder()
|
|
||||||
.setCustomId('cancel_close')
|
|
||||||
.setLabel('Cancel Close')
|
|
||||||
.setStyle(ButtonStyle.Secondary)
|
|
||||||
);
|
|
||||||
await interaction.update({ content: `Closing ticket in ${timerSeconds} seconds.`, components: [cancelRow] });
|
|
||||||
const timerId = setTimeout(async () => {
|
|
||||||
const pending = pendingCloses.get(interaction.channel.id);
|
|
||||||
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(() => {});
|
|
||||||
const effectiveSendEmail = pending?.sendEmail ?? true;
|
|
||||||
await handleConfirmClose(interaction, freshTicket, effectiveSendEmail);
|
|
||||||
}, timerSeconds * 1000);
|
|
||||||
pendingCloses.set(interaction.channel.id, { timeout: timerId, userId: interaction.user.id, username: interaction.user.tag, sendEmail });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (interaction.customId === 'cancel_close') {
|
|
||||||
const pending = pendingCloses.get(interaction.channel.id);
|
|
||||||
if (pending) {
|
|
||||||
clearTimeout(pending.timeout);
|
|
||||||
pendingCloses.delete(interaction.channel.id);
|
|
||||||
}
|
|
||||||
return interaction.update({ content: 'Close cancelled.', components: [] });
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- ESCALATE (prompt for tier 2 or 3) ---
|
|
||||||
if (interaction.customId === 'escalate_ticket') {
|
|
||||||
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
|
||||||
if (currentTier >= 2) {
|
|
||||||
return interaction.reply({ content: 'This ticket is already at tier 3 support.', ephemeral: true });
|
|
||||||
}
|
|
||||||
const escalateButtons = [];
|
|
||||||
if (currentTier < 1) {
|
|
||||||
escalateButtons.push(
|
|
||||||
new ButtonBuilder()
|
|
||||||
.setCustomId('escalate_to_tier2')
|
|
||||||
.setLabel('To Tier 2')
|
|
||||||
.setStyle(ButtonStyle.Secondary)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (currentTier < 2) {
|
|
||||||
escalateButtons.push(
|
|
||||||
new ButtonBuilder()
|
|
||||||
.setCustomId('escalate_to_tier3')
|
|
||||||
.setLabel('To Tier 3')
|
|
||||||
.setStyle(ButtonStyle.Secondary)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const choiceRow = new ActionRowBuilder().addComponents(escalateButtons);
|
|
||||||
return interaction.reply({
|
|
||||||
content: 'Escalate to which tier?',
|
|
||||||
components: [choiceRow],
|
|
||||||
ephemeral: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (interaction.customId === 'escalate_to_tier2') {
|
|
||||||
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
|
||||||
if (currentTier >= 1) {
|
|
||||||
return interaction.reply({ content: 'This ticket is already at tier 2.', ephemeral: true });
|
|
||||||
}
|
|
||||||
const categoryId = ticket.gmailThreadId.startsWith('discord-')
|
|
||||||
? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID
|
|
||||||
: CONFIG.EMAIL_ESCALATED2_CHANNEL_ID;
|
|
||||||
if (!categoryId && !interaction.channel.isThread()) {
|
|
||||||
return interaction.reply({ content: 'Tier 2 (ESCALATED2) is not configured for this ticket type.', ephemeral: true });
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await interaction.deferReply();
|
|
||||||
await runEscalation(interaction, ticket, 1, null);
|
|
||||||
} catch (err) {
|
|
||||||
logError('escalate-button-tier2', err, interaction).catch(() => {});
|
|
||||||
await interaction.editReply({ content: 'Failed to escalate to tier 2.' }).catch(() =>
|
|
||||||
interaction.followUp({ content: 'Failed to escalate to tier 2.', ephemeral: true }).catch(() => {})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (interaction.customId === 'escalate_to_tier3') {
|
|
||||||
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
|
||||||
if (currentTier >= 2) {
|
|
||||||
return interaction.reply({ content: 'This ticket is already at tier 3.', ephemeral: true });
|
|
||||||
}
|
|
||||||
const categoryId = ticket.gmailThreadId.startsWith('discord-')
|
|
||||||
? CONFIG.DISCORD_ESCALATED3_CHANNEL_ID
|
|
||||||
: CONFIG.EMAIL_ESCALATED3_CHANNEL_ID;
|
|
||||||
if (!categoryId && !interaction.channel.isThread()) {
|
|
||||||
return interaction.reply({ content: 'Tier 3 (ESCALATED3) is not configured for this ticket type.', ephemeral: true });
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await interaction.deferReply();
|
|
||||||
await runEscalation(interaction, ticket, 2, null);
|
|
||||||
} catch (err) {
|
|
||||||
logError('escalate-button-tier3', err, interaction).catch(() => {});
|
|
||||||
await interaction.editReply({ content: 'Failed to escalate to tier 3.' }).catch(() =>
|
|
||||||
interaction.followUp({ content: 'Failed to escalate to tier 3.', ephemeral: true }).catch(() => {})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- DEESCALATE ---
|
|
||||||
if (interaction.customId === 'deescalate_ticket') {
|
|
||||||
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
|
||||||
if (currentTier === 0) {
|
|
||||||
return interaction.reply({ content: 'This ticket is not escalated.', ephemeral: true });
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await interaction.deferReply({ ephemeral: true });
|
|
||||||
await runDeescalation(interaction, ticket);
|
|
||||||
} catch (err) {
|
|
||||||
logError('deescalate-button', err, interaction).catch(() => {});
|
|
||||||
await interaction.editReply({ content: 'Failed to deescalate this ticket.' }).catch(() =>
|
|
||||||
interaction.followUp({ content: 'Failed to deescalate this ticket.', ephemeral: true }).catch(() => {})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- TAG DELETE CONFIRM ---
|
|
||||||
if (interaction.customId.startsWith('confirm_delete_tag::')) {
|
|
||||||
const tagName = interaction.customId.slice('confirm_delete_tag::'.length);
|
const tagName = interaction.customId.slice('confirm_delete_tag::'.length);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await Tag.deleteOne({ name: tagName });
|
const result = await Tag.deleteOne({ name: tagName });
|
||||||
|
|
||||||
if (result.deletedCount === 0) {
|
if (result.deletedCount === 0) {
|
||||||
await interaction.update({
|
await interaction.update({ content: `❌ Tag "${tagName}" not found.`, components: [] });
|
||||||
content: `❌ Tag "${tagName}" not found.`,
|
|
||||||
components: []
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
await interaction.update({
|
await interaction.update({ content: `✅ Tag "${tagName}" deleted successfully.`, components: [] });
|
||||||
content: `✅ Tag "${tagName}" deleted successfully.`,
|
|
||||||
components: []
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logError('tag-delete-confirm', err, interaction).catch(() => {});
|
logError('tag-delete-confirm', err, interaction).catch(() => {});
|
||||||
await interaction.update({
|
await interaction.update({ content: '❌ Failed to delete tag.', components: [] });
|
||||||
content: '❌ Failed to delete tag.',
|
|
||||||
components: []
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (interaction.customId === 'cancel_delete_tag') {
|
// ============================================================
|
||||||
return interaction.update({ content: 'Tag deletion cancelled.', components: [] });
|
// Ticket-scoped button handlers
|
||||||
}
|
// ============================================================
|
||||||
|
|
||||||
// Priority is set via /priority slash command only; no priority buttons in tickets.
|
/** Toggle claim/unclaim on the current ticket and rewrite the action row. */
|
||||||
}
|
async function handleClaimButton(interaction, ticket) {
|
||||||
|
|
||||||
// --- CLAIM LOGIC ---
|
|
||||||
async function handleClaim(interaction, ticket) {
|
|
||||||
const freshTicket = await Ticket.findOne({ gmailThreadId: ticket.gmailThreadId }).lean();
|
const freshTicket = await Ticket.findOne({ gmailThreadId: ticket.gmailThreadId }).lean();
|
||||||
if (!freshTicket) {
|
if (!freshTicket) {
|
||||||
return interaction.reply({ content: 'Ticket data missing.', ephemeral: true });
|
return interaction.reply({ content: 'Ticket data missing.', ephemeral: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const isClaimed = !!freshTicket.claimedBy;
|
const isClaimed = !!freshTicket.claimedBy;
|
||||||
const claimerLabel =
|
const claimerLabel = interaction.member?.displayName || interaction.user.username;
|
||||||
interaction.member?.displayName || interaction.user.username;
|
|
||||||
const guild = interaction.guild;
|
const guild = interaction.guild;
|
||||||
const isClaimedByMe = freshTicket.claimedBy === claimerLabel;
|
const isClaimedByMe = freshTicket.claimedBy === claimerLabel;
|
||||||
|
|
||||||
@@ -322,7 +136,6 @@ async function handleClaim(interaction, ticket) {
|
|||||||
|
|
||||||
const row = ActionRowBuilder.from(row0);
|
const row = ActionRowBuilder.from(row0);
|
||||||
const [btnClose, btnClaim] = row.components;
|
const [btnClose, btnClaim] = row.components;
|
||||||
|
|
||||||
if (!btnClose || !btnClaim) {
|
if (!btnClose || !btnClaim) {
|
||||||
return interaction.reply({ content: 'Buttons missing.', ephemeral: true });
|
return interaction.reply({ content: 'Buttons missing.', ephemeral: true });
|
||||||
}
|
}
|
||||||
@@ -334,7 +147,15 @@ async function handleClaim(interaction, ticket) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isClaimed || (isClaimed && !isClaimedByMe && CONFIG.ALLOW_CLAIM_OVERWRITE)) {
|
const isClaiming = !isClaimed || (isClaimed && !isClaimedByMe && CONFIG.ALLOW_CLAIM_OVERWRITE);
|
||||||
|
if (isClaiming) {
|
||||||
|
await applyClaim(interaction, freshTicket, row, btnClose, btnClaim, claimerLabel, guild);
|
||||||
|
} else {
|
||||||
|
await applyUnclaim(interaction, freshTicket, row, btnClose, btnClaim, claimerLabel, guild);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyClaim(interaction, freshTicket, row, btnClose, btnClaim, claimerLabel, guild) {
|
||||||
await Ticket.updateOne(
|
await Ticket.updateOne(
|
||||||
{ gmailThreadId: freshTicket.gmailThreadId },
|
{ gmailThreadId: freshTicket.gmailThreadId },
|
||||||
{ $set: { claimedBy: claimerLabel, claimerId: interaction.user.id } }
|
{ $set: { claimedBy: claimerLabel, claimerId: interaction.user.id } }
|
||||||
@@ -344,12 +165,9 @@ async function handleClaim(interaction, ticket) {
|
|||||||
|
|
||||||
const claimerEmoji = CONFIG.STAFF_EMOJIS[interaction.user.id] || CONFIG.CLAIMER_EMOJI_FALLBACK;
|
const claimerEmoji = CONFIG.STAFF_EMOJIS[interaction.user.id] || CONFIG.CLAIMER_EMOJI_FALLBACK;
|
||||||
const creatorNickname = await resolveCreatorNickname(guild, freshTicket);
|
const creatorNickname = await resolveCreatorNickname(guild, freshTicket);
|
||||||
|
|
||||||
const state = freshTicket.escalated ? 'escalated-claimed' : 'claimed';
|
const state = freshTicket.escalated ? 'escalated-claimed' : 'claimed';
|
||||||
const newName = makeTicketName(state, freshTicket, creatorNickname, claimerEmoji);
|
enqueueRename(interaction.channel, makeTicketName(state, freshTicket, creatorNickname, claimerEmoji))
|
||||||
enqueueRename(interaction.channel, newName).catch(err => logError('rename', err).catch(() => {}));
|
.catch(err => logError('rename', err).catch(() => {}));
|
||||||
|
|
||||||
const label = `Unclaim (${claimerLabel})`;
|
|
||||||
|
|
||||||
btnClose
|
btnClose
|
||||||
.setCustomId('close_ticket')
|
.setCustomId('close_ticket')
|
||||||
@@ -363,9 +181,10 @@ async function handleClaim(interaction, ticket) {
|
|||||||
.setEmoji(CONFIG.BUTTON_EMOJI_UNCLAIM)
|
.setEmoji(CONFIG.BUTTON_EMOJI_UNCLAIM)
|
||||||
.setStyle(ButtonStyle.Secondary)
|
.setStyle(ButtonStyle.Secondary)
|
||||||
.setDisabled(false)
|
.setDisabled(false)
|
||||||
.setLabel(label);
|
.setLabel(`Unclaim (${claimerLabel})`);
|
||||||
|
|
||||||
await interaction.update({ components: [row] });
|
await interaction.update({ components: [row] });
|
||||||
|
|
||||||
const claimText = CONFIG.TICKET_CLAIMED_MESSAGE
|
const claimText = CONFIG.TICKET_CLAIMED_MESSAGE
|
||||||
.replace(/\{staff_mention\}/g, interaction.user.toString())
|
.replace(/\{staff_mention\}/g, interaction.user.toString())
|
||||||
.replace(/\{staff_name\}/g, interaction.member?.displayName || interaction.user.username);
|
.replace(/\{staff_name\}/g, interaction.member?.displayName || interaction.user.username);
|
||||||
@@ -375,10 +194,11 @@ async function handleClaim(interaction, ticket) {
|
|||||||
.setColor(CONFIG.EMBED_COLOR_CLAIMED)
|
.setColor(CONFIG.EMBED_COLOR_CLAIMED)
|
||||||
.setFooter({ text: `Claimed by ${claimerLabel}` });
|
.setFooter({ text: `Claimed by ${claimerLabel}` });
|
||||||
await interaction.followUp({ embeds: [claimEmbed] });
|
await interaction.followUp({ embeds: [claimEmbed] });
|
||||||
const { addMemberToStaffThread } = require('../services/staffThread');
|
|
||||||
await addMemberToStaffThread(interaction.channel, interaction.user.id).catch(() => {});
|
await addMemberToStaffThread(interaction.channel, interaction.user.id).catch(() => {});
|
||||||
} else {
|
}
|
||||||
// Unclaim
|
|
||||||
|
async function applyUnclaim(interaction, freshTicket, row, btnClose, btnClaim, claimerLabel, guild) {
|
||||||
await Ticket.updateOne(
|
await Ticket.updateOne(
|
||||||
{ gmailThreadId: freshTicket.gmailThreadId },
|
{ gmailThreadId: freshTicket.gmailThreadId },
|
||||||
{ $set: { claimedBy: null, claimerId: null } }
|
{ $set: { claimedBy: null, claimerId: null } }
|
||||||
@@ -386,9 +206,10 @@ async function handleClaim(interaction, ticket) {
|
|||||||
freshTicket.claimedBy = null;
|
freshTicket.claimedBy = null;
|
||||||
freshTicket.claimerId = null;
|
freshTicket.claimerId = null;
|
||||||
|
|
||||||
const creatorNicknameUnclaim = await resolveCreatorNickname(guild, freshTicket);
|
const creatorNickname = await resolveCreatorNickname(guild, freshTicket);
|
||||||
const unclaimState = (freshTicket.escalationTier ?? 0) >= 1 ? 'escalated' : 'unclaimed';
|
const state = (freshTicket.escalationTier ?? 0) >= 1 ? 'escalated' : 'unclaimed';
|
||||||
enqueueRename(interaction.channel, makeTicketName(unclaimState, freshTicket, creatorNicknameUnclaim)).catch(err => logError('rename', err).catch(() => {}));
|
enqueueRename(interaction.channel, makeTicketName(state, freshTicket, creatorNickname))
|
||||||
|
.catch(err => logError('rename', err).catch(() => {}));
|
||||||
|
|
||||||
btnClose
|
btnClose
|
||||||
.setCustomId('close_ticket')
|
.setCustomId('close_ticket')
|
||||||
@@ -405,6 +226,7 @@ async function handleClaim(interaction, ticket) {
|
|||||||
.setLabel(CONFIG.BUTTON_LABEL_CLAIM);
|
.setLabel(CONFIG.BUTTON_LABEL_CLAIM);
|
||||||
|
|
||||||
await interaction.update({ components: [row] });
|
await interaction.update({ components: [row] });
|
||||||
|
|
||||||
const unclaimText = CONFIG.TICKET_UNCLAIMED_MESSAGE
|
const unclaimText = CONFIG.TICKET_UNCLAIMED_MESSAGE
|
||||||
.replace(/\{staff_mention\}/g, interaction.user.toString())
|
.replace(/\{staff_mention\}/g, interaction.user.toString())
|
||||||
.replace(/\{staff_name\}/g, interaction.member?.displayName || interaction.user.username);
|
.replace(/\{staff_name\}/g, interaction.member?.displayName || interaction.user.username);
|
||||||
@@ -415,72 +237,187 @@ async function handleClaim(interaction, ticket) {
|
|||||||
.setFooter({ text: `Unclaimed by ${claimerLabel}` });
|
.setFooter({ text: `Unclaimed by ${claimerLabel}` });
|
||||||
await interaction.followUp({ embeds: [unclaimEmbed] });
|
await interaction.followUp({ embeds: [unclaimEmbed] });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First-stage Close button: prompt the staff member with confirm/cancel
|
||||||
|
* variants. Email tickets get a "Confirm Close With Email" / "Without Email"
|
||||||
|
* choice; Discord-only tickets get a single "Confirm Close".
|
||||||
|
*/
|
||||||
|
async function handleCloseButton(interaction, ticket) {
|
||||||
|
const isEmailTicket = ticket.gmailThreadId && !ticket.gmailThreadId.startsWith('discord-');
|
||||||
|
const buttons = [];
|
||||||
|
|
||||||
|
if (isEmailTicket) {
|
||||||
|
buttons.push(
|
||||||
|
new ButtonBuilder().setCustomId('confirm_close_with_email').setLabel('Confirm Close With Email').setStyle(ButtonStyle.Danger),
|
||||||
|
new ButtonBuilder().setCustomId('confirm_close_no_email').setLabel('Confirm Close Without Email').setStyle(ButtonStyle.Danger)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
buttons.push(
|
||||||
|
new ButtonBuilder().setCustomId('confirm_close').setLabel('Confirm Close').setStyle(ButtonStyle.Danger)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
buttons.push(
|
||||||
|
new ButtonBuilder().setCustomId('cancel_close').setLabel('Cancel').setStyle(ButtonStyle.Secondary)
|
||||||
|
);
|
||||||
|
|
||||||
|
return interaction.reply({
|
||||||
|
content: 'Are you sure you want to close this ticket?',
|
||||||
|
components: [new ActionRowBuilder().addComponents(...buttons)]
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- CONFIRM CLOSE ---
|
/**
|
||||||
async function handleConfirmClose(interaction, ticket, sendEmail = true) {
|
* Confirm-close button (any of `confirm_close`, `confirm_close_with_email`,
|
||||||
|
* `confirm_close_no_email`). Starts a countdown; staff can hit `cancel_close`
|
||||||
|
* to abort. After the timer elapses, runFinalClose() does the archive+delete.
|
||||||
|
*/
|
||||||
|
async function handleConfirmCloseRequest(interaction, ticket) {
|
||||||
|
const sendEmail = interaction.customId !== 'confirm_close_no_email';
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelRow = new ActionRowBuilder().addComponents(
|
||||||
|
new ButtonBuilder().setCustomId('cancel_close').setLabel('Cancel Close').setStyle(ButtonStyle.Secondary)
|
||||||
|
);
|
||||||
|
await interaction.update({ content: `Closing ticket in ${timerSeconds} seconds.`, components: [cancelRow] });
|
||||||
|
|
||||||
|
const channelId = interaction.channel.id;
|
||||||
|
const channelName = interaction.channel.name;
|
||||||
|
const userTag = interaction.user.tag;
|
||||||
|
|
||||||
|
const timerId = setTimeout(async () => {
|
||||||
|
const pending = pendingCloses.get(channelId);
|
||||||
|
pendingCloses.delete(channelId);
|
||||||
|
const freshTicket = await Ticket.findOne({ discordThreadId: channelId }).lean();
|
||||||
|
if (!freshTicket || freshTicket.status === 'closed') return;
|
||||||
|
|
||||||
|
logTicketEvent('Force-close timer fired', [
|
||||||
|
{ name: 'Ticket', value: channelName || channelId },
|
||||||
|
{ name: 'Set by', value: userTag },
|
||||||
|
{ name: 'Duration', value: `${timerSeconds}s` }
|
||||||
|
]).catch(() => {});
|
||||||
|
|
||||||
|
const effectiveSendEmail = pending?.sendEmail ?? true;
|
||||||
|
await runFinalClose(interaction, freshTicket, effectiveSendEmail);
|
||||||
|
}, timerSeconds * 1000);
|
||||||
|
|
||||||
|
pendingCloses.set(channelId, { timeout: timerId, userId: interaction.user.id, username: userTag, sendEmail });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCancelCloseRequest(interaction) {
|
||||||
|
const pending = pendingCloses.get(interaction.channel.id);
|
||||||
|
if (pending) {
|
||||||
|
clearTimeout(pending.timeout);
|
||||||
|
pendingCloses.delete(interaction.channel.id);
|
||||||
|
}
|
||||||
|
return interaction.update({ content: 'Close cancelled.', components: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escalate button: shows a tier 2 / tier 3 picker. The picker buttons are
|
||||||
|
* `escalate_to_tier2` / `escalate_to_tier3`, handled by handleEscalateButton.
|
||||||
|
*/
|
||||||
|
async function handleEscalatePrompt(interaction, ticket) {
|
||||||
|
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
||||||
|
if (currentTier >= 2) {
|
||||||
|
return interaction.reply({ content: 'This ticket is already at tier 3 support.', ephemeral: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const buttons = [];
|
||||||
|
if (currentTier < 1) {
|
||||||
|
buttons.push(new ButtonBuilder().setCustomId('escalate_to_tier2').setLabel('To Tier 2').setStyle(ButtonStyle.Secondary));
|
||||||
|
}
|
||||||
|
if (currentTier < 2) {
|
||||||
|
buttons.push(new ButtonBuilder().setCustomId('escalate_to_tier3').setLabel('To Tier 3').setStyle(ButtonStyle.Secondary));
|
||||||
|
}
|
||||||
|
|
||||||
|
return interaction.reply({
|
||||||
|
content: 'Escalate to which tier?',
|
||||||
|
components: [new ActionRowBuilder().addComponents(buttons)],
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tier-pick button (`escalate_to_tier2` or `escalate_to_tier3`). Validates
|
||||||
|
* the target tier, then delegates to runEscalation() in handlers/commands.js.
|
||||||
|
*/
|
||||||
|
async function handleEscalateButton(interaction, ticket) {
|
||||||
|
const tier = interaction.customId === 'escalate_to_tier3' ? 2 : 1;
|
||||||
|
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
||||||
|
|
||||||
|
if (currentTier >= tier) {
|
||||||
|
return interaction.reply({ content: `This ticket is already at tier ${tier + 1}.`, ephemeral: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
|
||||||
|
const categoryId = tier === 1
|
||||||
|
? (isDiscordTicket ? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID : CONFIG.EMAIL_ESCALATED2_CHANNEL_ID)
|
||||||
|
: (isDiscordTicket ? CONFIG.DISCORD_ESCALATED3_CHANNEL_ID : CONFIG.EMAIL_ESCALATED3_CHANNEL_ID);
|
||||||
|
|
||||||
|
if (!categoryId && !interaction.channel.isThread()) {
|
||||||
|
return interaction.reply({
|
||||||
|
content: `Tier ${tier + 1} (ESCALATED${tier + 1}) is not configured for this ticket type.`,
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await runDeferred(interaction, 'escalate', () => runEscalation(interaction, ticket, tier, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeescalateButton(interaction, ticket) {
|
||||||
|
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
||||||
|
if (currentTier === 0) {
|
||||||
|
return interaction.reply({ content: 'This ticket is not escalated.', ephemeral: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
await runDeferred(interaction, 'deescalate',
|
||||||
|
() => runDeescalation(interaction, ticket),
|
||||||
|
{ ephemeral: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Final close: archive → transcript → delete
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs after the force-close countdown elapses (or the staff member
|
||||||
|
* confirmed without a countdown). Archives the channel into a transcript,
|
||||||
|
* posts to the transcript channel and optionally DMs the creator, sends the
|
||||||
|
* customer closure email (email tickets only), then deletes the channel.
|
||||||
|
*/
|
||||||
|
async function runFinalClose(interaction, ticket, sendEmail = true) {
|
||||||
const closedAt = new Date();
|
const closedAt = new Date();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await interaction.update({ content: 'Archiving and closing...', components: [] });
|
await interaction.update({ content: 'Archiving and closing...', components: [] });
|
||||||
} catch {
|
} catch {
|
||||||
// Already acknowledged – fall back to editReply
|
// Already acknowledged – fall back to editReply
|
||||||
await interaction.editReply({ content: 'Archiving and closing...', components: [] }).catch(() => {});
|
await interaction.editReply({ content: 'Archiving and closing...', components: [] }).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
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');
|
|
||||||
|
|
||||||
const file = new AttachmentBuilder(Buffer.from(log), {
|
|
||||||
name: `transcript-${interaction.channel.name}.txt`
|
|
||||||
});
|
|
||||||
|
|
||||||
const channelName = interaction.channel.name;
|
const channelName = interaction.channel.name;
|
||||||
const opened = new Date(ticket.createdAt);
|
const transcriptText = await buildTranscriptText(interaction.channel, ticket);
|
||||||
const openedStr = opened.toLocaleString('en-US', {
|
const file = new AttachmentBuilder(Buffer.from(transcriptText), {
|
||||||
month: '2-digit',
|
name: `transcript-${channelName}.txt`
|
||||||
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'
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// In-ticket message before transcript is posted (Discord close message)
|
const openedStr = formatDateForTranscript(ticket.createdAt);
|
||||||
const discordCloseContent = CONFIG.DISCORD_CLOSE_MESSAGE;
|
const closedStr = formatDateForTranscript(closedAt);
|
||||||
await enqueueSend(interaction.channel, discordCloseContent);
|
const transcriptContent = renderTranscriptHeader(channelName, ticket.senderEmail, openedStr, closedStr);
|
||||||
|
|
||||||
|
await enqueueSend(interaction.channel, CONFIG.DISCORD_CLOSE_MESSAGE);
|
||||||
|
|
||||||
const transcriptChan = await interaction.client.channels
|
|
||||||
.fetch(CONFIG.TRANSCRIPT_CHAN)
|
|
||||||
.catch(() => null);
|
|
||||||
let transcriptMsg = null;
|
let transcriptMsg = null;
|
||||||
|
const transcriptChan = await interaction.client.channels
|
||||||
const transcriptContent = CONFIG.DISCORD_TRANSCRIPT_MESSAGE
|
.fetch(CONFIG.TRANSCRIPT_CHANNEL_ID)
|
||||||
.replace(/\{channel_name\}/g, channelName)
|
.catch(() => null);
|
||||||
.replace(/\{email\}/g, ticket.senderEmail || '')
|
|
||||||
.replace(/\{date_opened\}/g, openedStr)
|
|
||||||
.replace(/\{date_closed\}/g, closedStr)
|
|
||||||
+ `\n\nDate Opened: ${openedStr}\nDate Closed: ${closedStr}`;
|
|
||||||
|
|
||||||
if (transcriptChan) {
|
if (transcriptChan) {
|
||||||
transcriptMsg = await enqueueSend(transcriptChan, {
|
transcriptMsg = await enqueueSend(transcriptChan, {
|
||||||
content: transcriptContent,
|
content: transcriptContent,
|
||||||
@@ -488,62 +425,20 @@ async function handleConfirmClose(interaction, ticket, sendEmail = true) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// DM the transcript to the ticket creator (Discord-originated tickets).
|
// Optionally DM the transcript to the ticket creator. Many users have
|
||||||
// Gated because many users have DMs from server members disabled — the send
|
// server-member DMs disabled; gated to avoid 50007 noise. Discord-origin
|
||||||
// then 50007s and generates noise. Default off; enable via env when desired.
|
// tickets only.
|
||||||
if (CONFIG.TRANSCRIPT_DM_TO_CREATOR && ticket.gmailThreadId?.startsWith('discord-')) {
|
if (CONFIG.TRANSCRIPT_DM_TO_CREATOR && ticket.gmailThreadId?.startsWith('discord-')) {
|
||||||
const creatorId = ticket.gmailThreadId.split('-').pop();
|
await dmTranscriptToCreator(interaction.client, ticket, channelName, transcriptText, openedStr, closedStr);
|
||||||
try {
|
|
||||||
const creator = await interaction.client.users.fetch(creatorId);
|
|
||||||
const dmFile = new AttachmentBuilder(Buffer.from(log), {
|
|
||||||
name: `transcript-${channelName}.txt`
|
|
||||||
});
|
|
||||||
const dmContent = CONFIG.DISCORD_TRANSCRIPT_MESSAGE
|
|
||||||
.replace(/\{channel_name\}/g, channelName)
|
|
||||||
.replace(/\{email\}/g, ticket.senderEmail || '')
|
|
||||||
.replace(/\{date_opened\}/g, openedStr)
|
|
||||||
.replace(/\{date_closed\}/g, closedStr);
|
|
||||||
await creator.send({
|
|
||||||
content: dmContent,
|
|
||||||
files: [dmFile]
|
|
||||||
});
|
|
||||||
} catch (dmErr) {
|
|
||||||
// 50007 = "Cannot send messages to this user" — user has DMs off. Expected; ignore.
|
|
||||||
if (dmErr?.code !== 50007) {
|
|
||||||
logError('transcript-dm', dmErr).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const logChan = await interaction.client.channels
|
await postCloseLogEntry(interaction, ticket, channelName);
|
||||||
.fetch(CONFIG.LOG_CHAN)
|
|
||||||
.catch(() => null);
|
|
||||||
if (logChan) {
|
|
||||||
const closerMention = interaction.user.toString();
|
|
||||||
const closerDisplayName = interaction.member?.displayName || interaction.user.username;
|
const closerDisplayName = interaction.member?.displayName || interaction.user.username;
|
||||||
|
|
||||||
let logMsg;
|
|
||||||
if (ticket.gmailThreadId?.startsWith('discord-')) {
|
|
||||||
const creatorId = ticket.gmailThreadId.split('-').pop();
|
|
||||||
try {
|
|
||||||
const creator = await interaction.client.users.fetch(creatorId);
|
|
||||||
const creatorMention = creator.toString();
|
|
||||||
logMsg = `Closed ${creatorMention}'s **${channelName}** by ${closerMention} (${closerDisplayName})`;
|
|
||||||
} catch {
|
|
||||||
logMsg = `Closed **${channelName}** by ${closerMention} (${closerDisplayName})`;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logMsg = `Closed **${channelName}** (${ticket.senderEmail}) by ${closerMention} (${closerDisplayName})`;
|
|
||||||
}
|
|
||||||
await enqueueSend(logChan, logMsg);
|
|
||||||
}
|
|
||||||
|
|
||||||
const closerDisplayName =
|
|
||||||
interaction.member?.displayName || interaction.user.username;
|
|
||||||
|
|
||||||
if (sendEmail && !ticket.gmailThreadId?.startsWith('discord-')) {
|
if (sendEmail && !ticket.gmailThreadId?.startsWith('discord-')) {
|
||||||
await sendTicketClosedEmail(ticket, closerDisplayName, interaction.user.id);
|
await sendTicketClosedEmail(ticket, closerDisplayName, interaction.user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
await Ticket.updateOne(
|
await Ticket.updateOne(
|
||||||
{ gmailThreadId: ticket.gmailThreadId },
|
{ gmailThreadId: ticket.gmailThreadId },
|
||||||
{ $set: { discordThreadId: null, status: 'closed' } }
|
{ $set: { discordThreadId: null, status: 'closed' } }
|
||||||
@@ -560,25 +455,92 @@ async function handleConfirmClose(interaction, ticket, sendEmail = true) {
|
|||||||
const parentCatId = ticket.parentCategoryId;
|
const parentCatId = ticket.parentCategoryId;
|
||||||
const guildRef = interaction.guild;
|
const guildRef = interaction.guild;
|
||||||
|
|
||||||
setTimeout(
|
setTimeout(() => interaction.channel.delete().catch(() => {}), 5000);
|
||||||
() => interaction.channel.delete().catch(() => {}),
|
|
||||||
5000
|
|
||||||
);
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
(async () => {
|
|
||||||
if (parentCatId && guildRef) {
|
if (parentCatId && guildRef) {
|
||||||
await cleanupEmptyOverflowCategory(guildRef, parentCatId, CONFIG.TICKET_CATEGORY_NAME);
|
cleanupEmptyOverflowCategory(guildRef, parentCatId, CONFIG.TICKET_CATEGORY_NAME).catch(() => {});
|
||||||
}
|
}
|
||||||
})();
|
|
||||||
}, 6000);
|
}, 6000);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Close ticket error:', e);
|
console.error('Close ticket error:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Render the last 100 messages of a channel as a plaintext transcript. */
|
||||||
* Handle the ticket_modal submission (from the open-ticket panel button).
|
async function buildTranscriptText(channel, ticket) {
|
||||||
*/
|
const messages = await channel.messages.fetch({ limit: 100 });
|
||||||
|
return `TRANSCRIPT: ${ticket.subject}\nUser: ${ticket.senderEmail}\n---\n` +
|
||||||
|
messages
|
||||||
|
.reverse()
|
||||||
|
.map(m => `[${m.createdAt.toLocaleString()}] ${m.author.tag}: ${m.cleanContent}`)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateForTranscript(d) {
|
||||||
|
return new Date(d).toLocaleString('en-US', {
|
||||||
|
month: '2-digit', day: '2-digit', year: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||||
|
hour12: true, timeZoneName: 'short'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTranscriptHeader(channelName, senderEmail, openedStr, closedStr) {
|
||||||
|
return CONFIG.DISCORD_TRANSCRIPT_MESSAGE
|
||||||
|
.replace(/\{channel_name\}/g, channelName)
|
||||||
|
.replace(/\{email\}/g, senderEmail || '')
|
||||||
|
.replace(/\{date_opened\}/g, openedStr)
|
||||||
|
.replace(/\{date_closed\}/g, closedStr)
|
||||||
|
+ `\n\nDate Opened: ${openedStr}\nDate Closed: ${closedStr}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dmTranscriptToCreator(client, ticket, channelName, transcriptText, openedStr, closedStr) {
|
||||||
|
const creatorId = ticket.gmailThreadId.split('-').pop();
|
||||||
|
try {
|
||||||
|
const creator = await client.users.fetch(creatorId);
|
||||||
|
const dmFile = new AttachmentBuilder(Buffer.from(transcriptText), {
|
||||||
|
name: `transcript-${channelName}.txt`
|
||||||
|
});
|
||||||
|
const dmContent = CONFIG.DISCORD_TRANSCRIPT_MESSAGE
|
||||||
|
.replace(/\{channel_name\}/g, channelName)
|
||||||
|
.replace(/\{email\}/g, ticket.senderEmail || '')
|
||||||
|
.replace(/\{date_opened\}/g, openedStr)
|
||||||
|
.replace(/\{date_closed\}/g, closedStr);
|
||||||
|
await creator.send({ content: dmContent, files: [dmFile] });
|
||||||
|
} catch (dmErr) {
|
||||||
|
// 50007 = "Cannot send messages to this user" — user has DMs off. Expected; ignore.
|
||||||
|
if (dmErr?.code !== 50007) {
|
||||||
|
logError('transcript-dm', dmErr).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postCloseLogEntry(interaction, ticket, channelName) {
|
||||||
|
if (!CONFIG.LOGGING_CHANNEL_ID) return;
|
||||||
|
const logChan = await interaction.client.channels.fetch(CONFIG.LOGGING_CHANNEL_ID).catch(() => null);
|
||||||
|
if (!logChan) return;
|
||||||
|
|
||||||
|
const closerMention = interaction.user.toString();
|
||||||
|
const closerDisplayName = interaction.member?.displayName || interaction.user.username;
|
||||||
|
|
||||||
|
let logMsg;
|
||||||
|
if (ticket.gmailThreadId?.startsWith('discord-')) {
|
||||||
|
const creatorId = ticket.gmailThreadId.split('-').pop();
|
||||||
|
try {
|
||||||
|
const creator = await interaction.client.users.fetch(creatorId);
|
||||||
|
logMsg = `Closed ${creator.toString()}'s **${channelName}** by ${closerMention} (${closerDisplayName})`;
|
||||||
|
} catch {
|
||||||
|
logMsg = `Closed **${channelName}** by ${closerMention} (${closerDisplayName})`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logMsg = `Closed **${channelName}** (${ticket.senderEmail}) by ${closerMention} (${closerDisplayName})`;
|
||||||
|
}
|
||||||
|
await enqueueSend(logChan, logMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Ticket-creation modal submit (open-ticket panel → modal → ticket channel)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
async function handleTicketModal(interaction) {
|
async function handleTicketModal(interaction) {
|
||||||
await interaction.deferReply({ ephemeral: true });
|
await interaction.deferReply({ ephemeral: true });
|
||||||
|
|
||||||
@@ -599,14 +561,12 @@ async function handleTicketModal(interaction) {
|
|||||||
const lastTicket = await Ticket.findOne().sort({ ticketNumber: -1 }).select('ticketNumber').lean();
|
const lastTicket = await Ticket.findOne().sort({ ticketNumber: -1 }).select('ticketNumber').lean();
|
||||||
const ticketNumber = (lastTicket?.ticketNumber || 0) + 1;
|
const ticketNumber = (lastTicket?.ticketNumber || 0) + 1;
|
||||||
|
|
||||||
const creatorNicknameModal = interaction.member?.displayName || interaction.user.username;
|
const creatorNickname = interaction.member?.displayName || interaction.user.username;
|
||||||
const unclaimedName = toDiscordSafeName(`unclaimed-${creatorNicknameModal}-${ticketNumber}`);
|
const unclaimedName = toDiscordSafeName(`unclaimed-${creatorNickname}-${ticketNumber}`);
|
||||||
|
|
||||||
let channel;
|
let parentCategoryIdForTicket;
|
||||||
let parentCategoryIdForTicket = null;
|
|
||||||
let parentId;
|
|
||||||
try {
|
try {
|
||||||
parentId = await getOrCreateTicketCategory(
|
parentCategoryIdForTicket = await getOrCreateTicketCategory(
|
||||||
guild,
|
guild,
|
||||||
CONFIG.DISCORD_TICKET_CATEGORY_ID,
|
CONFIG.DISCORD_TICKET_CATEGORY_ID,
|
||||||
CONFIG.TICKET_CATEGORY_NAME
|
CONFIG.TICKET_CATEGORY_NAME
|
||||||
@@ -615,13 +575,14 @@ async function handleTicketModal(interaction) {
|
|||||||
console.error('getOrCreateTicketCategory (ticket modal):', err);
|
console.error('getOrCreateTicketCategory (ticket modal):', err);
|
||||||
return interaction.editReply('Discord ticket category could not be resolved. Contact an administrator.');
|
return interaction.editReply('Discord ticket category could not be resolved. Contact an administrator.');
|
||||||
}
|
}
|
||||||
parentCategoryIdForTicket = parentId;
|
|
||||||
|
let channel;
|
||||||
try {
|
try {
|
||||||
// TODO(queue-migrate): initial permissionOverwrites here are fine since the channel is just being created, but any later permissionOverwrites mutation on this channel should go through channelQueue.
|
// TODO(queue-migrate): initial permissionOverwrites here are fine since the channel is just being created, but any later permissionOverwrites mutation on this channel should go through channelQueue.
|
||||||
channel = await guild.channels.create({
|
channel = await guild.channels.create({
|
||||||
name: unclaimedName,
|
name: unclaimedName,
|
||||||
type: ChannelType.GuildText,
|
type: ChannelType.GuildText,
|
||||||
parent: parentId,
|
parent: parentCategoryIdForTicket,
|
||||||
permissionOverwrites: [
|
permissionOverwrites: [
|
||||||
{ id: guild.id, deny: [PermissionFlagsBits.ViewChannel] },
|
{ id: guild.id, deny: [PermissionFlagsBits.ViewChannel] },
|
||||||
{
|
{
|
||||||
@@ -655,6 +616,29 @@ async function handleTicketModal(interaction) {
|
|||||||
parentCategoryId: parentCategoryIdForTicket
|
parentCategoryId: parentCategoryIdForTicket
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const welcomeMsg = await postTicketWelcomeEmbeds(channel, interaction, email, game, description);
|
||||||
|
await createStaffThread(channel, interaction.client).catch(() => {});
|
||||||
|
|
||||||
|
if (CONFIG.PIN_INITIAL_MESSAGE_ENABLED && welcomeMsg) {
|
||||||
|
await pinMessage(welcomeMsg, interaction.client).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.deleteReply().catch(() => {});
|
||||||
|
|
||||||
|
if (CONFIG.LOGGING_CHANNEL_ID) {
|
||||||
|
const logChan = await interaction.client.channels.fetch(CONFIG.LOGGING_CHANNEL_ID).catch(() => null);
|
||||||
|
if (logChan) {
|
||||||
|
await enqueueSend(logChan, `📝 ${channel.name} created by ${interaction.user.tag}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Ticket creation error:', err);
|
||||||
|
await interaction.editReply('Failed to create ticket. Please contact an administrator.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build and send the welcome / info / resources embeds when a ticket is created via the modal. */
|
||||||
|
async function postTicketWelcomeEmbeds(channel, interaction, email, game, description) {
|
||||||
const descTrimmed = description.length > 500 ? description.slice(0, 497) + '…' : description;
|
const descTrimmed = description.length > 500 ? description.slice(0, 497) + '…' : description;
|
||||||
|
|
||||||
const welcomeEmbed = new EmbedBuilder()
|
const welcomeEmbed = new EmbedBuilder()
|
||||||
@@ -682,7 +666,6 @@ async function handleTicketModal(interaction) {
|
|||||||
|
|
||||||
const actionRow = getTicketActionRow({ escalationTier: 0 });
|
const actionRow = getTicketActionRow({ escalationTier: 0 });
|
||||||
|
|
||||||
enforceEmbedLimit([welcomeEmbed, infoEmbed, resourcesEmbed]);
|
|
||||||
let welcomeMsg;
|
let welcomeMsg;
|
||||||
try {
|
try {
|
||||||
welcomeMsg = await enqueueSend(channel, {
|
welcomeMsg = await enqueueSend(channel, {
|
||||||
@@ -698,27 +681,55 @@ async function handleTicketModal(interaction) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('welcomeMessageId-save', err);
|
console.error('welcomeMessageId-save', err);
|
||||||
}
|
}
|
||||||
|
return welcomeMsg;
|
||||||
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(() => {});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await interaction.deleteReply().catch(() => {});
|
// ============================================================
|
||||||
|
// Dispatch tables
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
const logChan = await interaction.client.channels.fetch(CONFIG.LOG_CHAN).catch(() => null);
|
/** Buttons that don't depend on a ticket-bound channel. */
|
||||||
if (logChan) {
|
const FREE_BUTTON_HANDLERS = {
|
||||||
await enqueueSend(logChan,
|
open_ticket: handleOpenTicketModal,
|
||||||
`📝 ${channel.name} created by ${interaction.user.tag}`
|
open_ticket_thread: handleOpenTicketModal,
|
||||||
|
open_ticket_channel: handleOpenTicketModal,
|
||||||
|
cancel_delete_tag: handleTagDeleteCancel
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Buttons that fire inside a ticket channel. The dispatcher does the lookup. */
|
||||||
|
const TICKET_BUTTON_HANDLERS = {
|
||||||
|
claim_ticket: handleClaimButton,
|
||||||
|
close_ticket: handleCloseButton,
|
||||||
|
confirm_close: handleConfirmCloseRequest,
|
||||||
|
confirm_close_with_email: handleConfirmCloseRequest,
|
||||||
|
confirm_close_no_email: handleConfirmCloseRequest,
|
||||||
|
cancel_close: handleCancelCloseRequest,
|
||||||
|
escalate_ticket: handleEscalatePrompt,
|
||||||
|
escalate_to_tier2: handleEscalateButton,
|
||||||
|
escalate_to_tier3: handleEscalateButton,
|
||||||
|
deescalate_ticket: handleDeescalateButton
|
||||||
|
};
|
||||||
|
|
||||||
|
async function handleButton(interaction) {
|
||||||
|
const { customId } = interaction;
|
||||||
|
|
||||||
|
// Tag-delete confirm has a dynamic id (`confirm_delete_tag::<name>`).
|
||||||
|
if (customId.startsWith('confirm_delete_tag::')) {
|
||||||
|
return handleTagDeleteConfirm(interaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
const freeHandler = FREE_BUTTON_HANDLERS[customId];
|
||||||
|
if (freeHandler) return freeHandler(interaction);
|
||||||
|
|
||||||
|
const ticketHandler = TICKET_BUTTON_HANDLERS[customId];
|
||||||
|
if (!ticketHandler) return;
|
||||||
|
|
||||||
|
const ticket = await findTicketForChannel(
|
||||||
|
interaction,
|
||||||
|
'This channel is not linked to a ticket, or the ticket could not be found.'
|
||||||
);
|
);
|
||||||
}
|
if (!ticket) return;
|
||||||
} catch (err) {
|
return ticketHandler(interaction, ticket);
|
||||||
console.error('Ticket creation error:', err);
|
|
||||||
await interaction.editReply('Failed to create ticket. Please contact an administrator.');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { handleButton, handleTicketModal };
|
module.exports = { handleButton, handleTicketModal };
|
||||||
|
|||||||
@@ -1,50 +1,51 @@
|
|||||||
/**
|
/**
|
||||||
* Slash command, context menu, and autocomplete handlers.
|
* Slash command, context menu, and autocomplete handlers.
|
||||||
|
*
|
||||||
|
* The dispatcher pattern: handleCommand looks up the command name in
|
||||||
|
* COMMAND_HANDLERS and delegates. Each handle<Command>() owns one slash
|
||||||
|
* command. To find a command's implementation, search for handle<Name>.
|
||||||
*/
|
*/
|
||||||
const {
|
const {
|
||||||
ChannelType,
|
ChannelType,
|
||||||
|
ActionRowBuilder,
|
||||||
ButtonBuilder,
|
ButtonBuilder,
|
||||||
ButtonStyle,
|
ButtonStyle,
|
||||||
AttachmentBuilder,
|
AttachmentBuilder,
|
||||||
EmbedBuilder,
|
EmbedBuilder,
|
||||||
|
ModalBuilder,
|
||||||
|
TextInputBuilder,
|
||||||
|
TextInputStyle,
|
||||||
PermissionFlagsBits
|
PermissionFlagsBits
|
||||||
} = require('discord.js');
|
} = require('discord.js');
|
||||||
const { mongoose } = require('../db-connection');
|
const { mongoose } = require('../db-connection');
|
||||||
const { CONFIG } = require('../config');
|
const { CONFIG } = require('../config');
|
||||||
const { getPriorityEmoji, replaceVariables } = require('../utils');
|
const { getPriorityEmoji, replaceVariables, isStaff } = require('../utils');
|
||||||
const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, checkTicketCreationRateLimit } = require('../services/tickets');
|
const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, checkTicketCreationRateLimit } = require('../services/tickets');
|
||||||
const { sendTicketNotificationEmail } = require('../services/gmail');
|
const { sendTicketNotificationEmail } = require('../services/gmail');
|
||||||
const { getTicketActionRow } = require('../utils/ticketComponents');
|
const { getTicketActionRow } = require('../utils/ticketComponents');
|
||||||
const { enqueueRename, enqueueMove, enqueueSend } = require('../services/channelQueue');
|
const { enqueueRename, enqueueMove, enqueueSend } = require('../services/channelQueue');
|
||||||
const { setNotifyDm } = require('../services/staffSettings');
|
const { setNotifyDm } = require('../services/staffSettings');
|
||||||
const { logError } = require('../services/debugLog');
|
const { pinMessage } = require('../services/pinMessage');
|
||||||
|
const { logError, logTicketEvent } = require('../services/debugLog');
|
||||||
const { pendingCloses } = require('./pendingCloses');
|
const { pendingCloses } = require('./pendingCloses');
|
||||||
|
const { findTicketForChannel, runDeferred } = require('./sharedHelpers');
|
||||||
|
|
||||||
const Ticket = mongoose.model('Ticket');
|
const Ticket = mongoose.model('Ticket');
|
||||||
const Tag = mongoose.model('Tag');
|
const Tag = mongoose.model('Tag');
|
||||||
|
const StaffSignature = mongoose.model('StaffSignature');
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Helpers
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True if member has the support role (ROLE_ID_TO_PING) or any ADDITIONAL_STAFF_ROLES.
|
* Reply ephemeral and return true if the interaction is in a guild and the
|
||||||
* Used to restrict commands to staff only; customers cannot use bot commands.
|
* user is not staff (so the caller should bail).
|
||||||
* @param {import('discord.js').GuildMember|null} member
|
|
||||||
* @returns {boolean}
|
|
||||||
*/
|
|
||||||
function hasStaffRole(member) {
|
|
||||||
if (!member?.roles?.cache) return false;
|
|
||||||
if (CONFIG.ROLE_ID_TO_PING && member.roles.cache.has(CONFIG.ROLE_ID_TO_PING)) return true;
|
|
||||||
const additional = CONFIG.ADDITIONAL_STAFF_ROLES || [];
|
|
||||||
return additional.some(roleId => member.roles.cache.has(roleId));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reply ephemeral and return true if the interaction is in a guild and the user is not staff (so caller should return).
|
|
||||||
* @param {import('discord.js').CommandInteraction|import('discord.js').ContextMenuCommandInteraction} interaction
|
|
||||||
* @returns {Promise<boolean>} true if caller should return (user is not allowed)
|
|
||||||
*/
|
*/
|
||||||
async function requireStaffRole(interaction) {
|
async function requireStaffRole(interaction) {
|
||||||
if (!interaction.guild) return false;
|
if (!interaction.guild) return false;
|
||||||
if (!CONFIG.ROLE_ID_TO_PING && (!CONFIG.ADDITIONAL_STAFF_ROLES || CONFIG.ADDITIONAL_STAFF_ROLES.length === 0)) return false;
|
if (!CONFIG.ROLE_ID_TO_PING && (!CONFIG.ADDITIONAL_STAFF_ROLES || CONFIG.ADDITIONAL_STAFF_ROLES.length === 0)) return false;
|
||||||
if (hasStaffRole(interaction.member)) return false;
|
if (isStaff(interaction.member)) return false;
|
||||||
const roleMention = CONFIG.ROLE_ID_TO_PING ? `<@&${CONFIG.ROLE_ID_TO_PING}>` : 'support';
|
const roleMention = CONFIG.ROLE_ID_TO_PING ? `<@&${CONFIG.ROLE_ID_TO_PING}>` : 'support';
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: `This command is only available to the support team (${roleMention}).`,
|
content: `This command is only available to the support team (${roleMention}).`,
|
||||||
@@ -53,8 +54,19 @@ async function requireStaffRole(interaction) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fetch the configured logging channel, or null if unset/missing. */
|
||||||
|
async function fetchLoggingChannel(client) {
|
||||||
|
if (!CONFIG.LOGGING_CHANNEL_ID) return null;
|
||||||
|
return client.channels.fetch(CONFIG.LOGGING_CHANNEL_ID).catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Escalation flows (reused by buttons via the module exports)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run escalation to a target DB tier (1 = tier 2, 2 = tier 3). Caller must validate ticket and currentTier < nextTier.
|
* Run escalation to a target tier (1 = tier 2, 2 = tier 3). Caller must
|
||||||
|
* validate ticket and currentTier < nextTier, and have already deferred.
|
||||||
*/
|
*/
|
||||||
async function runEscalation(interaction, ticket, nextTier, reason) {
|
async function runEscalation(interaction, ticket, nextTier, reason) {
|
||||||
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
|
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
|
||||||
@@ -89,9 +101,7 @@ async function runEscalation(interaction, ticket, nextTier, reason) {
|
|||||||
: null;
|
: null;
|
||||||
const creatorMention = creatorId ? `<@${creatorId}>` : '';
|
const creatorMention = creatorId ? `<@${creatorId}>` : '';
|
||||||
const roleMention = CONFIG.ROLE_ID_TO_PING ? `<@&${CONFIG.ROLE_ID_TO_PING}>` : 'a senior team member';
|
const roleMention = CONFIG.ROLE_ID_TO_PING ? `<@&${CONFIG.ROLE_ID_TO_PING}>` : 'a senior team member';
|
||||||
const heyLine = creatorMention
|
const heyLine = creatorMention ? `Hey There ${creatorMention} 🥦` : 'Hey There 🥦';
|
||||||
? `Hey There ${creatorMention} 🥦`
|
|
||||||
: 'Hey There 🥦';
|
|
||||||
// Creator + role pings are intentional; still block @everyone/@here if somehow interpolated.
|
// Creator + role pings are intentional; still block @everyone/@here if somehow interpolated.
|
||||||
await enqueueSend(interaction.channel, {
|
await enqueueSend(interaction.channel, {
|
||||||
content: `${heyLine}\n**Getting the senior ${roleMention} for you.**`,
|
content: `${heyLine}\n**Getting the senior ${roleMention} for you.**`,
|
||||||
@@ -115,7 +125,6 @@ async function runEscalation(interaction, ticket, nextTier, reason) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (CONFIG.PIN_ESCALATION_MESSAGE_ENABLED && escalationMsg) {
|
if (CONFIG.PIN_ESCALATION_MESSAGE_ENABLED && escalationMsg) {
|
||||||
const { pinMessage } = require('../services/pinMessage');
|
|
||||||
await pinMessage(escalationMsg, interaction.client).catch(() => {});
|
await pinMessage(escalationMsg, interaction.client).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,21 +133,13 @@ async function runEscalation(interaction, ticket, nextTier, reason) {
|
|||||||
const escalatorName = interaction.member?.displayName || interaction.user.username;
|
const escalatorName = interaction.member?.displayName || interaction.user.username;
|
||||||
const tierLabel = nextTier === 1 ? 'tier 2' : 'tier 3';
|
const tierLabel = nextTier === 1 ? 'tier 2' : 'tier 3';
|
||||||
const emailBody = `${escalatorName} escalated this ticket to ${tierLabel}.${reason ? `\n\nReason: ${reason}` : ''}`;
|
const emailBody = `${escalatorName} escalated this ticket to ${tierLabel}.${reason ? `\n\nReason: ${reason}` : ''}`;
|
||||||
await sendTicketNotificationEmail(
|
await sendTicketNotificationEmail(ticket, null, emailBody, interaction.user.id);
|
||||||
ticket,
|
|
||||||
null,
|
|
||||||
emailBody,
|
|
||||||
interaction.user.id
|
|
||||||
);
|
|
||||||
} catch (emailErr) {
|
} catch (emailErr) {
|
||||||
console.error('Escalation email failed (non-fatal):', emailErr.message);
|
console.error('Escalation email failed (non-fatal):', emailErr.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nextTier === 2) {
|
if (nextTier === 2 && ticket.welcomeMessageId) {
|
||||||
if (!ticket.welcomeMessageId) {
|
|
||||||
console.warn('welcomeMessageId is null/undefined; skipping welcome-message update for escalation');
|
|
||||||
} else {
|
|
||||||
try {
|
try {
|
||||||
const welcomeMsg = await interaction.channel.messages.fetch(ticket.welcomeMessageId);
|
const welcomeMsg = await interaction.channel.messages.fetch(ticket.welcomeMessageId);
|
||||||
await welcomeMsg.edit({ components: [getTicketActionRow(updatedTicketForRow)] });
|
await welcomeMsg.edit({ components: [getTicketActionRow(updatedTicketForRow)] });
|
||||||
@@ -146,11 +147,8 @@ async function runEscalation(interaction, ticket, nextTier, reason) {
|
|||||||
console.error('Failed to update welcome message after escalate:', e.message);
|
console.error('Failed to update welcome message after escalate:', e.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const logChan = await interaction.client.channels
|
const logChan = await fetchLoggingChannel(interaction.client);
|
||||||
.fetch(CONFIG.LOG_CHAN)
|
|
||||||
.catch(() => null);
|
|
||||||
if (logChan) {
|
if (logChan) {
|
||||||
const ticketType = isDiscordTicket ? 'Discord' : 'Email';
|
const ticketType = isDiscordTicket ? 'Discord' : 'Email';
|
||||||
const tierLabel = nextTier === 1 ? 'tier 2' : 'tier 3';
|
const tierLabel = nextTier === 1 ? 'tier 2' : 'tier 3';
|
||||||
@@ -160,9 +158,7 @@ async function runEscalation(interaction, ticket, nextTier, reason) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Run deescalation one step. Caller must validate ticket and currentTier >= 1. */
|
||||||
* Run deescalation one step. Caller must validate ticket and currentTier >= 1.
|
|
||||||
*/
|
|
||||||
async function runDeescalation(interaction, ticket) {
|
async function runDeescalation(interaction, ticket) {
|
||||||
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
||||||
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
|
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
|
||||||
@@ -203,7 +199,7 @@ async function runDeescalation(interaction, ticket) {
|
|||||||
.setFooter({ text: interaction.member?.displayName || interaction.user.username });
|
.setFooter({ text: interaction.member?.displayName || interaction.user.username });
|
||||||
await interaction.editReply({ embeds: [deescalateEmbed] });
|
await interaction.editReply({ embeds: [deescalateEmbed] });
|
||||||
|
|
||||||
const logChan = await interaction.client.channels.fetch(CONFIG.LOG_CHAN).catch(() => null);
|
const logChan = await fetchLoggingChannel(interaction.client);
|
||||||
if (logChan) {
|
if (logChan) {
|
||||||
const ticketType = isDiscordTicket ? 'Discord' : 'Email';
|
const ticketType = isDiscordTicket ? 'Discord' : 'Email';
|
||||||
await enqueueSend(logChan,
|
await enqueueSend(logChan,
|
||||||
@@ -212,29 +208,22 @@ async function runDeescalation(interaction, ticket) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ============================================================
|
||||||
* Main slash-command handler.
|
// Per-command handlers
|
||||||
*/
|
// ============================================================
|
||||||
async function handleCommand(interaction) {
|
|
||||||
// Only /help can be used by everyone; all other commands require staff role (ROLE_ID_TO_PING / ADDITIONAL_STAFF_ROLES)
|
|
||||||
if (interaction.commandName !== 'help' && (await requireStaffRole(interaction))) return;
|
|
||||||
|
|
||||||
// /escalate (tier 2 or 3 via level; works for both email and Discord). Always unclaims on escalate.
|
async function handleEscalate(interaction) {
|
||||||
if (interaction.commandName === 'escalate') {
|
|
||||||
const reason = null;
|
const reason = null;
|
||||||
const level = interaction.options.getString('level');
|
const level = interaction.options.getString('level');
|
||||||
const nextTier = level === '3' ? 2 : 1;
|
const nextTier = level === '3' ? 2 : 1;
|
||||||
|
|
||||||
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
|
const ticket = await findTicketForChannel(interaction);
|
||||||
if (!ticket) {
|
if (!ticket) return;
|
||||||
return interaction.reply({ content: 'This channel is not linked to a ticket.', ephemeral: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
||||||
if (currentTier >= 2) {
|
if (currentTier >= 2) {
|
||||||
return interaction.reply({ content: 'This ticket is already at tier 3 support.', ephemeral: true });
|
return interaction.reply({ content: 'This ticket is already at tier 3 support.', ephemeral: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nextTier <= currentTier) {
|
if (nextTier <= currentTier) {
|
||||||
return interaction.reply({ content: 'Ticket is already at or past that tier.', ephemeral: true });
|
return interaction.reply({ content: 'Ticket is already at or past that tier.', ephemeral: true });
|
||||||
}
|
}
|
||||||
@@ -251,18 +240,27 @@ async function handleCommand(interaction) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
await runDeferred(interaction, 'escalate', () =>
|
||||||
await interaction.deferReply();
|
runEscalation(interaction, ticket, nextTier, reason)
|
||||||
await runEscalation(interaction, ticket, nextTier, reason);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Escalate error:', err);
|
|
||||||
await interaction.editReply({ content: 'Failed to escalate this ticket.' }).catch(() =>
|
|
||||||
interaction.followUp({ content: 'Failed to escalate this ticket.', ephemeral: true }).catch(() => {})
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleDeescalate(interaction) {
|
||||||
|
const ticket = await findTicketForChannel(interaction);
|
||||||
|
if (!ticket) return;
|
||||||
|
|
||||||
|
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
||||||
|
if (currentTier === 0) {
|
||||||
|
return interaction.reply({ content: 'This ticket is not escalated.', ephemeral: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (interaction.commandName === 'notifydm') {
|
await runDeferred(interaction, 'de-escalate',
|
||||||
|
() => runDeescalation(interaction, ticket),
|
||||||
|
{ ephemeral: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleNotifyDm(interaction) {
|
||||||
try {
|
try {
|
||||||
const setting = interaction.options.getString('setting') === 'on';
|
const setting = interaction.options.getString('setting') === 'on';
|
||||||
await setNotifyDm(interaction.user.id, interaction.guildId, setting);
|
await setNotifyDm(interaction.user.id, interaction.guildId, setting);
|
||||||
@@ -274,40 +272,12 @@ async function handleCommand(interaction) {
|
|||||||
console.error('notifydm error:', err);
|
console.error('notifydm error:', err);
|
||||||
await interaction.reply({ content: 'Failed to update notification setting.', ephemeral: true }).catch(() => {});
|
await interaction.reply({ content: 'Failed to update notification setting.', ephemeral: true }).catch(() => {});
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// /deescalate (tier 3 → tier 2, tier 2 → normal)
|
async function handleAdd(interaction) {
|
||||||
if (interaction.commandName === 'deescalate') {
|
|
||||||
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
|
|
||||||
if (!ticket) {
|
|
||||||
return interaction.reply({ content: 'This channel is not linked to a ticket.', ephemeral: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
|
||||||
if (currentTier === 0) {
|
|
||||||
return interaction.reply({ content: 'This ticket is not escalated.', ephemeral: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await interaction.deferReply({ ephemeral: true });
|
|
||||||
await runDeescalation(interaction, ticket);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Deescalate error:', err);
|
|
||||||
await interaction.editReply({ content: 'Failed to deescalate this ticket.' }).catch(() =>
|
|
||||||
interaction.followUp({ content: 'Failed to deescalate this ticket.', ephemeral: true }).catch(() => {})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// /add
|
|
||||||
if (interaction.commandName === 'add') {
|
|
||||||
const user = interaction.options.getUser('user');
|
const user = interaction.options.getUser('user');
|
||||||
|
const ticket = await findTicketForChannel(interaction);
|
||||||
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
|
if (!ticket) return;
|
||||||
if (!ticket) {
|
|
||||||
return interaction.reply({ content: 'This channel is not linked to a ticket.', ephemeral: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// TODO(queue-migrate): permissionOverwrites mutation bypasses channelQueue — could race a pending rename/send on the same channel.
|
// TODO(queue-migrate): permissionOverwrites mutation bypasses channelQueue — could race a pending rename/send on the same channel.
|
||||||
@@ -323,14 +293,10 @@ async function handleCommand(interaction) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// /remove
|
async function handleRemove(interaction) {
|
||||||
if (interaction.commandName === 'remove') {
|
|
||||||
const user = interaction.options.getUser('user');
|
const user = interaction.options.getUser('user');
|
||||||
|
const ticket = await findTicketForChannel(interaction);
|
||||||
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
|
if (!ticket) return;
|
||||||
if (!ticket) {
|
|
||||||
return interaction.reply({ content: 'This channel is not linked to a ticket.', ephemeral: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// TODO(queue-migrate): permissionOverwrites mutation bypasses channelQueue — could race a pending rename/send on the same channel.
|
// TODO(queue-migrate): permissionOverwrites mutation bypasses channelQueue — could race a pending rename/send on the same channel.
|
||||||
@@ -342,15 +308,11 @@ async function handleCommand(interaction) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// /transfer
|
async function handleTransfer(interaction) {
|
||||||
if (interaction.commandName === 'transfer') {
|
|
||||||
const member = interaction.options.getUser('member');
|
const member = interaction.options.getUser('member');
|
||||||
const reason = interaction.options.getString('reason') || 'No reason provided';
|
const reason = interaction.options.getString('reason') || 'No reason provided';
|
||||||
|
const ticket = await findTicketForChannel(interaction);
|
||||||
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
|
if (!ticket) return;
|
||||||
if (!ticket) {
|
|
||||||
return interaction.reply({ content: 'This channel is not linked to a ticket.', ephemeral: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const staffRoleId = CONFIG.ROLE_TO_PING_ID;
|
const staffRoleId = CONFIG.ROLE_TO_PING_ID;
|
||||||
const guildMember = await interaction.guild.members.fetch(member.id).catch(() => null);
|
const guildMember = await interaction.guild.members.fetch(member.id).catch(() => null);
|
||||||
@@ -373,7 +335,7 @@ async function handleCommand(interaction) {
|
|||||||
allowedMentions: { parse: ['users'] }
|
allowedMentions: { parse: ['users'] }
|
||||||
});
|
});
|
||||||
|
|
||||||
const logChan = await interaction.client.channels.fetch(CONFIG.LOG_CHAN).catch(() => null);
|
const logChan = await fetchLoggingChannel(interaction.client);
|
||||||
if (logChan) {
|
if (logChan) {
|
||||||
await enqueueSend(logChan, {
|
await enqueueSend(logChan, {
|
||||||
content: `Ticket ${interaction.channel} transferred from ${interaction.user.tag} to ${member.tag}.\nReason: ${reason}`,
|
content: `Ticket ${interaction.channel} transferred from ${interaction.user.tag} to ${member.tag}.\nReason: ${reason}`,
|
||||||
@@ -386,21 +348,17 @@ async function handleCommand(interaction) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// /move
|
async function handleMove(interaction) {
|
||||||
if (interaction.commandName === 'move') {
|
|
||||||
const category = interaction.options.getChannel('category');
|
const category = interaction.options.getChannel('category');
|
||||||
|
const ticket = await findTicketForChannel(interaction);
|
||||||
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
|
if (!ticket) return;
|
||||||
if (!ticket) {
|
|
||||||
return interaction.reply({ content: 'This channel is not linked to a ticket.', ephemeral: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// TODO(queue-migrate): setParent bypasses channelQueue (enqueueMove) — use enqueueMove so moves serialize with pending renames/sends.
|
// TODO(queue-migrate): setParent bypasses channelQueue (enqueueMove) — use enqueueMove so moves serialize with pending renames/sends.
|
||||||
await interaction.channel.setParent(category.id, { lockPermissions: true });
|
await interaction.channel.setParent(category.id, { lockPermissions: true });
|
||||||
await interaction.reply(`Moved ticket to **${category.name}**.`);
|
await interaction.reply(`Moved ticket to **${category.name}**.`);
|
||||||
|
|
||||||
const logChan = await interaction.client.channels.fetch(CONFIG.LOG_CHAN).catch(() => null);
|
const logChan = await fetchLoggingChannel(interaction.client);
|
||||||
if (logChan) {
|
if (logChan) {
|
||||||
await enqueueSend(logChan,
|
await enqueueSend(logChan,
|
||||||
`Ticket ${interaction.channel} moved to category **${category.name}** by ${interaction.user.tag}`
|
`Ticket ${interaction.channel} moved to category **${category.name}** by ${interaction.user.tag}`
|
||||||
@@ -412,9 +370,7 @@ async function handleCommand(interaction) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// /gmailpoll
|
async function handleStaffThread(interaction) {
|
||||||
// /staffthread
|
|
||||||
if (interaction.commandName === 'staffthread') {
|
|
||||||
const sub = interaction.options.getSubcommand();
|
const sub = interaction.options.getSubcommand();
|
||||||
if (sub === 'toggle') {
|
if (sub === 'toggle') {
|
||||||
CONFIG.STAFF_THREAD_ENABLED = !CONFIG.STAFF_THREAD_ENABLED;
|
CONFIG.STAFF_THREAD_ENABLED = !CONFIG.STAFF_THREAD_ENABLED;
|
||||||
@@ -430,11 +386,9 @@ async function handleCommand(interaction) {
|
|||||||
CONFIG.STAFF_THREAD_AUTO_ADD_ROLE = 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 interaction.reply({ content: `Auto-add role to staff thread is now **${enabled ? 'enabled' : 'disabled'}**.`, ephemeral: true });
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// /pinmessages
|
async function handlePinMessages(interaction) {
|
||||||
if (interaction.commandName === 'pinmessages') {
|
|
||||||
const sub = interaction.options.getSubcommand();
|
const sub = interaction.options.getSubcommand();
|
||||||
const enabled = interaction.options.getBoolean('enabled');
|
const enabled = interaction.options.getBoolean('enabled');
|
||||||
if (sub === 'initial') {
|
if (sub === 'initial') {
|
||||||
@@ -449,33 +403,36 @@ async function handleCommand(interaction) {
|
|||||||
CONFIG.PIN_SUPPRESS_SYSTEM_MESSAGE = enabled;
|
CONFIG.PIN_SUPPRESS_SYSTEM_MESSAGE = enabled;
|
||||||
return interaction.reply({ content: `Suppress pin system message is now **${enabled ? 'enabled' : 'disabled'}**.`, ephemeral: true });
|
return interaction.reply({ content: `Suppress pin system message is now **${enabled ? 'enabled' : 'disabled'}**.`, ephemeral: true });
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (interaction.commandName === 'gmailpoll') {
|
async function handleGmailPoll(interaction) {
|
||||||
const seconds = parseInt(interaction.options.getString('interval'), 10);
|
const seconds = parseInt(interaction.options.getString('interval'), 10);
|
||||||
|
// Lazy require — broccolini-discord re-exports this and we'd otherwise cycle.
|
||||||
const { setGmailPollInterval } = require('../broccolini-discord');
|
const { setGmailPollInterval } = require('../broccolini-discord');
|
||||||
setGmailPollInterval(seconds * 1000);
|
setGmailPollInterval(seconds * 1000);
|
||||||
logTicketEvent('Gmail poll interval updated', [{ name: 'Interval', value: `${seconds}s` }, { name: 'Set by', value: interaction.user.tag }], interaction).catch(() => {});
|
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 });
|
return interaction.reply({ content: `Gmail poll interval set to ${seconds} seconds.`, ephemeral: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// /closetimer
|
async function handleCloseTimer(interaction) {
|
||||||
if (interaction.commandName === 'closetimer') {
|
|
||||||
const seconds = parseInt(interaction.options.getString('seconds'), 10);
|
const seconds = parseInt(interaction.options.getString('seconds'), 10);
|
||||||
CONFIG.FORCE_CLOSE_TIMER = seconds;
|
CONFIG.FORCE_CLOSE_TIMER = seconds;
|
||||||
logTicketEvent('Close timer updated', [{ name: 'Duration', value: `${seconds}s` }, { name: 'Set by', value: interaction.user.tag }], interaction).catch(() => {});
|
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 });
|
return interaction.reply({ content: `Force-close timer set to ${seconds} seconds.`, ephemeral: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// /cancel-close
|
async function handleCancelClose(interaction) {
|
||||||
if (interaction.commandName === 'cancel-close') {
|
|
||||||
const pending = pendingCloses.get(interaction.channel.id);
|
const pending = pendingCloses.get(interaction.channel.id);
|
||||||
if (!pending) {
|
if (!pending) {
|
||||||
return interaction.reply({ content: 'No pending close for this channel.', ephemeral: true });
|
return interaction.reply({ content: 'No pending close for this channel.', ephemeral: true });
|
||||||
}
|
}
|
||||||
clearTimeout(pending.timeout);
|
clearTimeout(pending.timeout);
|
||||||
const { logTicketEvent } = require('../services/debugLog');
|
|
||||||
logTicketEvent('Force-close cancelled', [
|
logTicketEvent('Force-close cancelled', [
|
||||||
{ name: 'Ticket', value: interaction.channel.name || interaction.channel.id },
|
{ name: 'Ticket', value: interaction.channel.name || interaction.channel.id },
|
||||||
{ name: 'Cancelled by', value: interaction.user.tag },
|
{ name: 'Cancelled by', value: interaction.user.tag },
|
||||||
@@ -485,12 +442,9 @@ async function handleCommand(interaction) {
|
|||||||
return interaction.reply({ content: 'Close cancelled.', ephemeral: true });
|
return interaction.reply({ content: 'Close cancelled.', ephemeral: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// /force-close
|
async function handleForceClose(interaction) {
|
||||||
if (interaction.commandName === 'force-close') {
|
const ticket = await findTicketForChannel(interaction);
|
||||||
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
|
if (!ticket) return;
|
||||||
if (!ticket) {
|
|
||||||
return interaction.reply({ content: 'This channel is not linked to a ticket.', ephemeral: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pendingCloses.has(interaction.channel.id)) {
|
if (pendingCloses.has(interaction.channel.id)) {
|
||||||
return interaction.reply({ content: 'A close is already pending for this ticket.', ephemeral: true });
|
return interaction.reply({ content: 'A close is already pending for this ticket.', ephemeral: true });
|
||||||
@@ -501,7 +455,12 @@ async function handleCommand(interaction) {
|
|||||||
|
|
||||||
const channelRef = interaction.channel;
|
const channelRef = interaction.channel;
|
||||||
const clientRef = interaction.client;
|
const clientRef = interaction.client;
|
||||||
const timerId = setTimeout(async () => {
|
const timerId = setTimeout(() => finalizeForceClose(channelRef, clientRef), timerSeconds * 1000);
|
||||||
|
pendingCloses.set(channelRef.id, { timeout: timerId, userId: interaction.user.id, username: interaction.user.tag });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Performs the actual force-close work after the countdown elapses. */
|
||||||
|
async function finalizeForceClose(channelRef, clientRef) {
|
||||||
pendingCloses.delete(channelRef.id);
|
pendingCloses.delete(channelRef.id);
|
||||||
const freshTicket = await Ticket.findOne({ discordThreadId: channelRef.id }).lean();
|
const freshTicket = await Ticket.findOne({ discordThreadId: channelRef.id }).lean();
|
||||||
if (!freshTicket || freshTicket.status === 'closed') return;
|
if (!freshTicket || freshTicket.status === 'closed') return;
|
||||||
@@ -513,8 +472,22 @@ async function handleCommand(interaction) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await enqueueSend(channelRef, 'Ticket force-closed. Archiving...');
|
await enqueueSend(channelRef, 'Ticket force-closed. Archiving...');
|
||||||
|
await postTranscript(channelRef, clientRef, freshTicket).catch(tErr =>
|
||||||
|
console.error('Transcript error (force-close):', tErr)
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
setTimeout(() => {
|
||||||
|
channelRef.delete('Ticket force-closed').catch(e =>
|
||||||
|
console.error('Failed to delete channel:', e)
|
||||||
|
);
|
||||||
|
}, 5000);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Force close error:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render and post a closing transcript for a ticket. */
|
||||||
|
async function postTranscript(channelRef, clientRef, freshTicket) {
|
||||||
await enqueueSend(channelRef, CONFIG.DISCORD_CLOSE_MESSAGE);
|
await enqueueSend(channelRef, CONFIG.DISCORD_CLOSE_MESSAGE);
|
||||||
|
|
||||||
const messages = await channelRef.messages.fetch({ limit: 100 });
|
const messages = await channelRef.messages.fetch({ limit: 100 });
|
||||||
@@ -530,58 +503,30 @@ async function handleCommand(interaction) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const transcriptChan = await clientRef.channels
|
const transcriptChan = await clientRef.channels
|
||||||
.fetch(CONFIG.TRANSCRIPT_CHAN)
|
.fetch(CONFIG.TRANSCRIPT_CHANNEL_ID)
|
||||||
.catch(() => null);
|
.catch(() => null);
|
||||||
|
if (!transcriptChan) return;
|
||||||
|
|
||||||
if (transcriptChan) {
|
const fmt = (d) => new Date(d).toLocaleString('en-US', {
|
||||||
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',
|
month: '2-digit', day: '2-digit', year: 'numeric',
|
||||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||||
hour12: true, timeZoneName: 'short'
|
hour12: true, timeZoneName: 'short'
|
||||||
});
|
});
|
||||||
|
const openedStr = fmt(freshTicket.createdAt);
|
||||||
|
const closedStr = fmt(new Date());
|
||||||
const transcriptContent = CONFIG.DISCORD_TRANSCRIPT_MESSAGE
|
const transcriptContent = CONFIG.DISCORD_TRANSCRIPT_MESSAGE
|
||||||
.replace(/\{channel_name\}/g, channelRef.name)
|
.replace(/\{channel_name\}/g, channelRef.name)
|
||||||
.replace(/\{email\}/g, freshTicket.senderEmail || '')
|
.replace(/\{email\}/g, freshTicket.senderEmail || '')
|
||||||
.replace(/\{date_opened\}/g, openedStr)
|
.replace(/\{date_opened\}/g, openedStr)
|
||||||
.replace(/\{date_closed\}/g, closedStr)
|
.replace(/\{date_closed\}/g, closedStr)
|
||||||
+ `\n\nDate Opened: ${openedStr}\nDate Closed: ${closedStr}`;
|
+ `\n\nDate Opened: ${openedStr}\nDate Closed: ${closedStr}`;
|
||||||
await enqueueSend(transcriptChan, {
|
await enqueueSend(transcriptChan, { content: transcriptContent, files: [file] });
|
||||||
content: transcriptContent,
|
|
||||||
files: [file]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (tErr) {
|
|
||||||
console.error('Transcript error (force-close):', tErr);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(async () => {
|
async function handleTopic(interaction) {
|
||||||
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
|
|
||||||
if (interaction.commandName === 'topic') {
|
|
||||||
const text = interaction.options.getString('text');
|
const text = interaction.options.getString('text');
|
||||||
|
const ticket = await findTicketForChannel(interaction);
|
||||||
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
|
if (!ticket) return;
|
||||||
if (!ticket) {
|
|
||||||
return interaction.reply({ content: 'This channel is not linked to a ticket.', ephemeral: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// TODO(queue-migrate): setTopic bypasses channelQueue — could race a pending rename/send on the same channel.
|
// TODO(queue-migrate): setTopic bypasses channelQueue — could race a pending rename/send on the same channel.
|
||||||
@@ -593,12 +538,33 @@ async function handleCommand(interaction) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// /response – saved response tags (send, create, edit, delete, list)
|
// /response is itself a router over its subcommands
|
||||||
if (interaction.commandName === 'response') {
|
const RESPONSE_SUBCOMMANDS = {
|
||||||
const subcommand = interaction.options.getSubcommand();
|
send: handleResponseSend,
|
||||||
|
create: handleResponseCreate,
|
||||||
|
edit: handleResponseEdit,
|
||||||
|
delete: handleResponseDelete,
|
||||||
|
list: handleResponseList
|
||||||
|
};
|
||||||
|
|
||||||
|
async function handleResponse(interaction) {
|
||||||
|
const subcommand = interaction.options.getSubcommand();
|
||||||
|
const handler = RESPONSE_SUBCOMMANDS[subcommand];
|
||||||
|
if (!handler) return;
|
||||||
try {
|
try {
|
||||||
if (subcommand === 'send') {
|
await handler(interaction);
|
||||||
|
} catch (err) {
|
||||||
|
logError('response-command', err, interaction).catch(() => {});
|
||||||
|
const errorMsg = '❌ An error occurred while processing the response command.';
|
||||||
|
if (interaction.deferred) {
|
||||||
|
await interaction.editReply(errorMsg);
|
||||||
|
} else {
|
||||||
|
await interaction.reply({ content: errorMsg, ephemeral: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleResponseSend(interaction) {
|
||||||
const name = interaction.options.getString('name');
|
const name = interaction.options.getString('name');
|
||||||
const tag = await Tag.findOne({ name }).lean();
|
const tag = await Tag.findOne({ name }).lean();
|
||||||
if (!tag) {
|
if (!tag) {
|
||||||
@@ -619,11 +585,11 @@ async function handleCommand(interaction) {
|
|||||||
const content = replaceVariables(tag.content, context);
|
const content = replaceVariables(tag.content, context);
|
||||||
await Tag.updateOne({ name }, { $inc: { useCount: 1 } });
|
await Tag.updateOne({ name }, { $inc: { useCount: 1 } });
|
||||||
// Tag bodies are staff-authored but may include variable substitutions from user/ticket data.
|
// Tag bodies are staff-authored but may include variable substitutions from user/ticket data.
|
||||||
// Disable all mention parsing so a `@everyone` in a tag body never pings.
|
// Disable mention parsing so a `@everyone` in a tag body never pings.
|
||||||
await interaction.reply({ content, allowedMentions: { parse: [] } });
|
await interaction.reply({ content, allowedMentions: { parse: [] } });
|
||||||
}
|
}
|
||||||
|
|
||||||
else if (subcommand === 'create') {
|
async function handleResponseCreate(interaction) {
|
||||||
const name = interaction.options.getString('name');
|
const name = interaction.options.getString('name');
|
||||||
const content = interaction.options.getString('content');
|
const content = interaction.options.getString('content');
|
||||||
|
|
||||||
@@ -640,13 +606,12 @@ async function handleCommand(interaction) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
else if (subcommand === 'edit') {
|
async function handleResponseEdit(interaction) {
|
||||||
const name = interaction.options.getString('name');
|
const name = interaction.options.getString('name');
|
||||||
const content = interaction.options.getString('content');
|
const content = interaction.options.getString('content');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await Tag.updateOne({ name }, { $set: { content } });
|
const result = await Tag.updateOne({ name }, { $set: { content } });
|
||||||
|
|
||||||
if (result.matchedCount === 0) {
|
if (result.matchedCount === 0) {
|
||||||
await interaction.reply({ content: `❌ Tag "${name}" not found.`, ephemeral: true });
|
await interaction.reply({ content: `❌ Tag "${name}" not found.`, ephemeral: true });
|
||||||
} else {
|
} else {
|
||||||
@@ -658,9 +623,9 @@ async function handleCommand(interaction) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
else if (subcommand === 'delete') {
|
async function handleResponseDelete(interaction) {
|
||||||
const name = interaction.options.getString('name');
|
const name = interaction.options.getString('name');
|
||||||
// Use :: delimiter so tag names with underscores are parsed correctly (Discord customId max 100 chars)
|
// Use :: delimiter so tag names with underscores parse correctly (Discord customId max 100 chars).
|
||||||
const customId = `confirm_delete_tag::${name}`.slice(0, 100);
|
const customId = `confirm_delete_tag::${name}`.slice(0, 100);
|
||||||
const confirmRow = new ActionRowBuilder().addComponents(
|
const confirmRow = new ActionRowBuilder().addComponents(
|
||||||
new ButtonBuilder()
|
new ButtonBuilder()
|
||||||
@@ -680,11 +645,10 @@ async function handleCommand(interaction) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
else if (subcommand === 'list') {
|
async function handleResponseList(interaction) {
|
||||||
await interaction.deferReply({ ephemeral: true });
|
await interaction.deferReply({ ephemeral: true });
|
||||||
|
|
||||||
const tags = await Tag.find().sort({ useCount: -1 }).select('name useCount').lean();
|
const tags = await Tag.find().sort({ useCount: -1 }).select('name useCount').lean();
|
||||||
|
|
||||||
if (!tags || tags.length === 0) {
|
if (!tags || tags.length === 0) {
|
||||||
return interaction.editReply({ content: '📋 No tags available.' });
|
return interaction.editReply({ content: '📋 No tags available.' });
|
||||||
}
|
}
|
||||||
@@ -699,31 +663,15 @@ async function handleCommand(interaction) {
|
|||||||
|
|
||||||
await interaction.editReply({ embeds: [embed] });
|
await interaction.editReply({ embeds: [embed] });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
logError('response-command', err, interaction).catch(() => {});
|
|
||||||
const errorMsg = '❌ An error occurred while processing the response command.';
|
|
||||||
if (interaction.deferred) {
|
|
||||||
await interaction.editReply(errorMsg);
|
|
||||||
} else {
|
|
||||||
await interaction.reply({ content: errorMsg, ephemeral: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// /signature
|
async function handleSignature(interaction) {
|
||||||
if (interaction.commandName === 'signature') {
|
|
||||||
try {
|
try {
|
||||||
// Fetch existing signature data if it exists
|
|
||||||
const StaffSignature = mongoose.model('StaffSignature');
|
|
||||||
const existingSignature = await StaffSignature.findOne({ userId: interaction.user.id }).lean();
|
const existingSignature = await StaffSignature.findOne({ userId: interaction.user.id }).lean();
|
||||||
|
|
||||||
// Create modal
|
|
||||||
const { ModalBuilder, ActionRowBuilder, TextInputBuilder, TextInputStyle } = require('discord.js');
|
|
||||||
const modal = new ModalBuilder()
|
const modal = new ModalBuilder()
|
||||||
.setCustomId(`signature_modal_${interaction.user.id}`)
|
.setCustomId(`signature_modal_${interaction.user.id}`)
|
||||||
.setTitle('Staff Signature Settings');
|
.setTitle('Staff Signature Settings');
|
||||||
|
|
||||||
// Add text inputs to modal
|
|
||||||
const valedictionInput = new TextInputBuilder()
|
const valedictionInput = new TextInputBuilder()
|
||||||
.setCustomId('valediction')
|
.setCustomId('valediction')
|
||||||
.setLabel('Valediction (e.g. "Best regards", "Thanks")')
|
.setLabel('Valediction (e.g. "Best regards", "Thanks")')
|
||||||
@@ -745,11 +693,11 @@ async function handleCommand(interaction) {
|
|||||||
.setRequired(false)
|
.setRequired(false)
|
||||||
.setValue(existingSignature?.tagline || '');
|
.setValue(existingSignature?.tagline || '');
|
||||||
|
|
||||||
const valedictionRow = new ActionRowBuilder().addComponents(valedictionInput);
|
modal.addComponents(
|
||||||
const displayNameRow = new ActionRowBuilder().addComponents(displayNameInput);
|
new ActionRowBuilder().addComponents(valedictionInput),
|
||||||
const taglineRow = new ActionRowBuilder().addComponents(taglineInput);
|
new ActionRowBuilder().addComponents(displayNameInput),
|
||||||
|
new ActionRowBuilder().addComponents(taglineInput)
|
||||||
modal.addComponents(valedictionRow, displayNameRow, taglineRow);
|
);
|
||||||
|
|
||||||
await interaction.showModal(modal);
|
await interaction.showModal(modal);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -758,11 +706,9 @@ async function handleCommand(interaction) {
|
|||||||
await interaction.reply({ content: 'Failed to open signature settings.', ephemeral: true }).catch(() => {});
|
await interaction.reply({ content: 'Failed to open signature settings.', ephemeral: true }).catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// /help
|
async function handleHelp(interaction) {
|
||||||
if (interaction.commandName === 'help') {
|
|
||||||
const embed = new EmbedBuilder()
|
const embed = new EmbedBuilder()
|
||||||
.setTitle('Ticket System - Commands')
|
.setTitle('Ticket System - Commands')
|
||||||
.setColor(CONFIG.EMBED_COLOR_OPEN)
|
.setColor(CONFIG.EMBED_COLOR_OPEN)
|
||||||
@@ -797,10 +743,9 @@ async function handleCommand(interaction) {
|
|||||||
await interaction.reply({ embeds: [embed], ephemeral: true });
|
await interaction.reply({ embeds: [embed], ephemeral: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// /panel
|
async function handlePanel(interaction) {
|
||||||
if (interaction.commandName === 'panel') {
|
|
||||||
const channel = interaction.options.getChannel('channel');
|
const channel = interaction.options.getChannel('channel');
|
||||||
const panelType = interaction.options.getString('type') || null; // 'thread' | 'category' | 'both' or null (use CONFIG default)
|
const panelType = interaction.options.getString('type') || null; // 'thread' | 'category' | 'both' or null
|
||||||
const title = interaction.options.getString('title') || 'Indifferent Broccoli Tickets';
|
const title = interaction.options.getString('title') || 'Indifferent Broccoli Tickets';
|
||||||
const description = interaction.options.getString('description') ||
|
const description = interaction.options.getString('description') ||
|
||||||
'Need help? Click below to create a ticket. 🎟';
|
'Need help? Click below to create a ticket. 🎟';
|
||||||
@@ -812,9 +757,20 @@ async function handleCommand(interaction) {
|
|||||||
.setThumbnail(CONFIG.LOGO_URL || null)
|
.setThumbnail(CONFIG.LOGO_URL || null)
|
||||||
.setFooter({ text: 'Indifferent Broccoli Tickets' });
|
.setFooter({ text: 'Indifferent Broccoli Tickets' });
|
||||||
|
|
||||||
let row;
|
const row = buildPanelButtonRow(panelType);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await enqueueSend(channel, { embeds: [embed], components: [row] });
|
||||||
|
await interaction.reply({ content: `Panel created in ${channel}!`, ephemeral: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Panel creation error:', err);
|
||||||
|
await interaction.reply({ content: 'Failed to create panel.', ephemeral: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPanelButtonRow(panelType) {
|
||||||
if (panelType === 'both') {
|
if (panelType === 'both') {
|
||||||
row = new ActionRowBuilder().addComponents(
|
return new ActionRowBuilder().addComponents(
|
||||||
new ButtonBuilder()
|
new ButtonBuilder()
|
||||||
.setCustomId('open_ticket_thread')
|
.setCustomId('open_ticket_thread')
|
||||||
.setLabel('Create ticket (thread)')
|
.setLabel('Create ticket (thread)')
|
||||||
@@ -826,24 +782,26 @@ async function handleCommand(interaction) {
|
|||||||
.setStyle(ButtonStyle.Secondary)
|
.setStyle(ButtonStyle.Secondary)
|
||||||
.setEmoji('📁')
|
.setEmoji('📁')
|
||||||
);
|
);
|
||||||
} else if (panelType === 'thread') {
|
}
|
||||||
row = new ActionRowBuilder().addComponents(
|
if (panelType === 'thread') {
|
||||||
|
return new ActionRowBuilder().addComponents(
|
||||||
new ButtonBuilder()
|
new ButtonBuilder()
|
||||||
.setCustomId('open_ticket_thread')
|
.setCustomId('open_ticket_thread')
|
||||||
.setLabel('Create ticket')
|
.setLabel('Create ticket')
|
||||||
.setStyle(ButtonStyle.Secondary)
|
.setStyle(ButtonStyle.Secondary)
|
||||||
.setEmoji('🧵')
|
.setEmoji('🧵')
|
||||||
);
|
);
|
||||||
} else if (panelType === 'category') {
|
}
|
||||||
row = new ActionRowBuilder().addComponents(
|
if (panelType === 'category') {
|
||||||
|
return new ActionRowBuilder().addComponents(
|
||||||
new ButtonBuilder()
|
new ButtonBuilder()
|
||||||
.setCustomId('open_ticket_channel')
|
.setCustomId('open_ticket_channel')
|
||||||
.setLabel('Create ticket')
|
.setLabel('Create ticket')
|
||||||
.setStyle(ButtonStyle.Secondary)
|
.setStyle(ButtonStyle.Secondary)
|
||||||
.setEmoji('📁')
|
.setEmoji('📁')
|
||||||
);
|
);
|
||||||
} else {
|
}
|
||||||
row = new ActionRowBuilder().addComponents(
|
return new ActionRowBuilder().addComponents(
|
||||||
new ButtonBuilder()
|
new ButtonBuilder()
|
||||||
.setCustomId('open_ticket')
|
.setCustomId('open_ticket')
|
||||||
.setLabel('Create ticket')
|
.setLabel('Create ticket')
|
||||||
@@ -852,26 +810,11 @@ async function handleCommand(interaction) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// ============================================================
|
||||||
await enqueueSend(channel, { embeds: [embed], components: [row] });
|
// Context-menu handlers
|
||||||
await interaction.reply({ content: `Panel created in ${channel}!`, ephemeral: true });
|
// ============================================================
|
||||||
} catch (err) {
|
|
||||||
console.error('Panel creation error:', err);
|
|
||||||
await interaction.reply({ content: 'Failed to create panel.', ephemeral: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
async function handleCreateTicketFromMessage(interaction) {
|
||||||
|
|
||||||
/**
|
|
||||||
* Context menu interaction handler.
|
|
||||||
*/
|
|
||||||
async function handleContextMenu(interaction) {
|
|
||||||
// Restrict all guild context menus to staff role only
|
|
||||||
if (await requireStaffRole(interaction)) return;
|
|
||||||
|
|
||||||
// Create Ticket From Message
|
|
||||||
if (interaction.isMessageContextMenuCommand() && interaction.commandName === 'Create Ticket From Message') {
|
|
||||||
await interaction.deferReply({ ephemeral: true });
|
await interaction.deferReply({ ephemeral: true });
|
||||||
|
|
||||||
const rateLimit = checkTicketCreationRateLimit(interaction.user.id);
|
const rateLimit = checkTicketCreationRateLimit(interaction.user.id);
|
||||||
@@ -889,11 +832,9 @@ async function handleContextMenu(interaction) {
|
|||||||
const lastTicket = await Ticket.findOne().sort({ ticketNumber: -1 }).select('ticketNumber').lean();
|
const lastTicket = await Ticket.findOne().sort({ ticketNumber: -1 }).select('ticketNumber').lean();
|
||||||
const ticketNumber = (lastTicket?.ticketNumber || 0) + 1;
|
const ticketNumber = (lastTicket?.ticketNumber || 0) + 1;
|
||||||
|
|
||||||
let channel;
|
let parentCategoryIdForTicket;
|
||||||
let parentCategoryIdForTicket = null;
|
|
||||||
let parentId;
|
|
||||||
try {
|
try {
|
||||||
parentId = await getOrCreateTicketCategory(
|
parentCategoryIdForTicket = await getOrCreateTicketCategory(
|
||||||
guild,
|
guild,
|
||||||
CONFIG.DISCORD_TICKET_CATEGORY_ID,
|
CONFIG.DISCORD_TICKET_CATEGORY_ID,
|
||||||
CONFIG.TICKET_CATEGORY_NAME
|
CONFIG.TICKET_CATEGORY_NAME
|
||||||
@@ -902,12 +843,13 @@ async function handleContextMenu(interaction) {
|
|||||||
console.error('getOrCreateTicketCategory (context menu ticket):', err);
|
console.error('getOrCreateTicketCategory (context menu ticket):', err);
|
||||||
return interaction.editReply('❌ Discord ticket category could not be resolved. Contact an administrator.');
|
return interaction.editReply('❌ Discord ticket category could not be resolved. Contact an administrator.');
|
||||||
}
|
}
|
||||||
parentCategoryIdForTicket = parentId;
|
|
||||||
|
let channel;
|
||||||
try {
|
try {
|
||||||
channel = await guild.channels.create({
|
channel = await guild.channels.create({
|
||||||
name: `ticket-${ticketNumber}`,
|
name: `ticket-${ticketNumber}`,
|
||||||
type: ChannelType.GuildText,
|
type: ChannelType.GuildText,
|
||||||
parent: parentId,
|
parent: parentCategoryIdForTicket,
|
||||||
permissionOverwrites: [
|
permissionOverwrites: [
|
||||||
{ id: guild.id, deny: [PermissionFlagsBits.ViewChannel] },
|
{ id: guild.id, deny: [PermissionFlagsBits.ViewChannel] },
|
||||||
{
|
{
|
||||||
@@ -977,13 +919,11 @@ async function handleContextMenu(interaction) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// View User Tickets
|
async function handleViewUserTickets(interaction) {
|
||||||
if (interaction.isUserContextMenuCommand() && interaction.commandName === 'View User Tickets') {
|
|
||||||
await interaction.deferReply({ ephemeral: true });
|
await interaction.deferReply({ ephemeral: true });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const targetUser = interaction.targetUser;
|
const targetUser = interaction.targetUser;
|
||||||
|
|
||||||
const tickets = await Ticket.find({ senderEmail: targetUser.tag })
|
const tickets = await Ticket.find({ senderEmail: targetUser.tag })
|
||||||
.sort({ createdAt: -1 })
|
.sort({ createdAt: -1 })
|
||||||
.limit(10)
|
.limit(10)
|
||||||
@@ -1018,18 +958,62 @@ async function handleContextMenu(interaction) {
|
|||||||
await interaction.editReply('❌ Failed to fetch user tickets.');
|
await interaction.editReply('❌ Failed to fetch user tickets.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// ============================================================
|
||||||
|
// Dispatch tables
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const COMMAND_HANDLERS = {
|
||||||
|
escalate: handleEscalate,
|
||||||
|
deescalate: handleDeescalate,
|
||||||
|
notifydm: handleNotifyDm,
|
||||||
|
add: handleAdd,
|
||||||
|
remove: handleRemove,
|
||||||
|
transfer: handleTransfer,
|
||||||
|
move: handleMove,
|
||||||
|
staffthread: handleStaffThread,
|
||||||
|
pinmessages: handlePinMessages,
|
||||||
|
gmailpoll: handleGmailPoll,
|
||||||
|
closetimer: handleCloseTimer,
|
||||||
|
'cancel-close': handleCancelClose,
|
||||||
|
'force-close': handleForceClose,
|
||||||
|
topic: handleTopic,
|
||||||
|
response: handleResponse,
|
||||||
|
signature: handleSignature,
|
||||||
|
help: handleHelp,
|
||||||
|
panel: handlePanel
|
||||||
|
};
|
||||||
|
|
||||||
|
const CONTEXT_MENU_HANDLERS = {
|
||||||
|
'Create Ticket From Message': handleCreateTicketFromMessage,
|
||||||
|
'View User Tickets': handleViewUserTickets
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Autocomplete handler.
|
* Slash-command dispatcher. /help is open to everyone; everything else
|
||||||
|
* requires the staff role.
|
||||||
*/
|
*/
|
||||||
|
async function handleCommand(interaction) {
|
||||||
|
if (interaction.commandName !== 'help' && (await requireStaffRole(interaction))) return;
|
||||||
|
const handler = COMMAND_HANDLERS[interaction.commandName];
|
||||||
|
if (handler) await handler(interaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Context-menu dispatcher. All entries are staff-only. */
|
||||||
|
async function handleContextMenu(interaction) {
|
||||||
|
if (await requireStaffRole(interaction)) return;
|
||||||
|
const handler = CONTEXT_MENU_HANDLERS[interaction.commandName];
|
||||||
|
if (handler) await handler(interaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Autocomplete handler. Currently only /response uses it. */
|
||||||
async function handleAutocomplete(interaction) {
|
async function handleAutocomplete(interaction) {
|
||||||
if (interaction.commandName === 'response') {
|
if (interaction.commandName !== 'response') return;
|
||||||
const subcommand = interaction.options.getSubcommand();
|
const subcommand = interaction.options.getSubcommand();
|
||||||
if (['send', 'edit', 'delete'].includes(subcommand)) {
|
if (!['send', 'edit', 'delete'].includes(subcommand)) return;
|
||||||
|
|
||||||
const focusedValue = interaction.options.getFocused();
|
const focusedValue = interaction.options.getFocused();
|
||||||
const tags = await Tag.find().sort({ name: 1 }).select('name').lean();
|
const tags = await Tag.find().sort({ name: 1 }).select('name').lean();
|
||||||
|
|
||||||
const filtered = tags
|
const filtered = tags
|
||||||
.filter(t => t.name.toLowerCase().includes(focusedValue.toLowerCase()))
|
.filter(t => t.name.toLowerCase().includes(focusedValue.toLowerCase()))
|
||||||
.slice(0, 25)
|
.slice(0, 25)
|
||||||
@@ -1037,7 +1021,5 @@ async function handleAutocomplete(interaction) {
|
|||||||
|
|
||||||
await interaction.respond(filtered);
|
await interaction.respond(filtered);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { handleCommand, handleContextMenu, handleAutocomplete, runEscalation, runDeescalation };
|
module.exports = { handleCommand, handleContextMenu, handleAutocomplete, runEscalation, runDeescalation };
|
||||||
53
handlers/sharedHelpers.js
Normal file
53
handlers/sharedHelpers.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Shared helpers for slash-command and button handlers.
|
||||||
|
*
|
||||||
|
* Both handlers/commands.js and handlers/buttons.js use these to avoid
|
||||||
|
* repeating the lookup-and-defer-and-try-catch pattern across 30+ branches.
|
||||||
|
*/
|
||||||
|
const { mongoose } = require('../db-connection');
|
||||||
|
const { logError } = require('../services/debugLog');
|
||||||
|
|
||||||
|
const Ticket = mongoose.model('Ticket');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up the ticket linked to this channel; reply with `missingMessage`
|
||||||
|
* (default: "This channel is not linked to a ticket.") and return null if
|
||||||
|
* the channel is not a ticket. Returns the ticket on success.
|
||||||
|
*
|
||||||
|
* @param {import('discord.js').Interaction} interaction
|
||||||
|
* @param {string} [missingMessage]
|
||||||
|
*/
|
||||||
|
async function findTicketForChannel(interaction, missingMessage = 'This channel is not linked to a ticket.') {
|
||||||
|
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
|
||||||
|
if (!ticket) {
|
||||||
|
await interaction.reply({ content: missingMessage, ephemeral: true });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return ticket;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defer + run + log + reply on error. `verb` is the user-facing verb
|
||||||
|
* (e.g. "escalate"); error messages render as "Failed to <verb> this ticket."
|
||||||
|
* Errors are logged to console + DEBUGGING_CHANNEL_ID via logError(verb, ...).
|
||||||
|
*
|
||||||
|
* @param {import('discord.js').Interaction} interaction
|
||||||
|
* @param {string} verb
|
||||||
|
* @param {() => Promise<void>} fn
|
||||||
|
* @param {{ ephemeral?: boolean }} [opts]
|
||||||
|
*/
|
||||||
|
async function runDeferred(interaction, verb, fn, { ephemeral = false } = {}) {
|
||||||
|
try {
|
||||||
|
await interaction.deferReply({ ephemeral });
|
||||||
|
await fn();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`${verb} error:`, err);
|
||||||
|
logError(verb, err, interaction).catch(() => {});
|
||||||
|
const msg = `Failed to ${verb} this ticket.`;
|
||||||
|
await interaction.editReply({ content: msg }).catch(() =>
|
||||||
|
interaction.followUp({ content: msg, ephemeral: true }).catch(() => {})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { findTicketForChannel, runDeferred };
|
||||||
@@ -69,7 +69,7 @@ async function logTicketEvent(action, fields, interaction = null) {
|
|||||||
if (interaction?.user?.tag) {
|
if (interaction?.user?.tag) {
|
||||||
embed.setFooter({ text: interaction.user.tag });
|
embed.setFooter({ text: interaction.user.tag });
|
||||||
}
|
}
|
||||||
await sendToChannel(CONFIG.LOG_CHAN, embed, interaction?.client);
|
await sendToChannel(CONFIG.LOGGING_CHANNEL_ID, embed, interaction?.client);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|||||||
74
utils.js
74
utils.js
@@ -264,83 +264,9 @@ function truncateEmbedDescription(str, max = 4096) {
|
|||||||
return s.length > max ? s.slice(0, max - 3) + '...' : s;
|
return s.length > max ? s.slice(0, max - 3) + '...' : s;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Enforce the 6 000 char total embed limit across an array of EmbedBuilder
|
|
||||||
* instances. Mutates in place: trims the largest description first, then
|
|
||||||
* largest field values, until the total is under 6 000 chars.
|
|
||||||
* Returns the same array for chaining.
|
|
||||||
*/
|
|
||||||
function enforceEmbedLimit(embeds) {
|
|
||||||
const charCount = (e) => {
|
|
||||||
const d = e.data || {};
|
|
||||||
let total = 0;
|
|
||||||
if (d.title) total += d.title.length;
|
|
||||||
if (d.description) total += d.description.length;
|
|
||||||
if (d.footer?.text) total += d.footer.text.length;
|
|
||||||
if (d.author?.name) total += d.author.name.length;
|
|
||||||
if (d.fields) {
|
|
||||||
for (const f of d.fields) {
|
|
||||||
if (f.name) total += f.name.length;
|
|
||||||
if (f.value) total += f.value.length;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return total;
|
|
||||||
};
|
|
||||||
|
|
||||||
const LIMIT = 6000;
|
|
||||||
|
|
||||||
const totalChars = () => embeds.reduce((sum, e) => sum + charCount(e), 0);
|
|
||||||
|
|
||||||
// Trim largest descriptions first
|
|
||||||
while (totalChars() > LIMIT) {
|
|
||||||
let largestIdx = -1;
|
|
||||||
let largestLen = 0;
|
|
||||||
for (let i = 0; i < embeds.length; i++) {
|
|
||||||
const desc = embeds[i].data?.description;
|
|
||||||
if (desc && desc.length > largestLen) {
|
|
||||||
largestLen = desc.length;
|
|
||||||
largestIdx = i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (largestIdx === -1 || largestLen <= 4) break;
|
|
||||||
const excess = totalChars() - LIMIT;
|
|
||||||
const newLen = Math.max(1, largestLen - excess - 3);
|
|
||||||
embeds[largestIdx].setDescription(
|
|
||||||
embeds[largestIdx].data.description.slice(0, newLen) + '...'
|
|
||||||
);
|
|
||||||
if (totalChars() <= LIMIT) break;
|
|
||||||
// If still over, loop will pick next largest
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trim largest field values
|
|
||||||
while (totalChars() > LIMIT) {
|
|
||||||
let targetEmbed = null;
|
|
||||||
let targetFieldIdx = -1;
|
|
||||||
let targetLen = 0;
|
|
||||||
for (const e of embeds) {
|
|
||||||
const fields = e.data?.fields || [];
|
|
||||||
for (let fi = 0; fi < fields.length; fi++) {
|
|
||||||
if (fields[fi].value && fields[fi].value.length > targetLen) {
|
|
||||||
targetLen = fields[fi].value.length;
|
|
||||||
targetEmbed = e;
|
|
||||||
targetFieldIdx = fi;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!targetEmbed || targetLen <= 4) break;
|
|
||||||
const excess = totalChars() - LIMIT;
|
|
||||||
const newLen = Math.max(1, targetLen - excess - 3);
|
|
||||||
targetEmbed.data.fields[targetFieldIdx].value =
|
|
||||||
targetEmbed.data.fields[targetFieldIdx].value.slice(0, newLen) + '...';
|
|
||||||
}
|
|
||||||
|
|
||||||
return embeds;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
sanitizeEmbedText,
|
sanitizeEmbedText,
|
||||||
truncateEmbedDescription,
|
truncateEmbedDescription,
|
||||||
enforceEmbedLimit,
|
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
safeEqual,
|
safeEqual,
|
||||||
isStaff,
|
isStaff,
|
||||||
|
|||||||
Reference in New Issue
Block a user