handleButton routed claim_ticket, close_ticket, confirm_close /
confirm_close_with_email / confirm_close_no_email, cancel_close,
escalate_ticket, escalate_to_tier2, escalate_to_tier3, deescalate_ticket,
and confirm_delete_tag::* straight to their handlers without any staff
check. Any non-staff member with View Channel on a ticket — the ticket
creator themselves, or anyone /add'd to it — could click those buttons
and mutate ticket state (claim, escalate, close, delete saved-response
tags).
The slash-command dispatcher in handlers/commands/index.js already
calls requireStaffRole before invoking any handler; the button
dispatcher needed the same gate. Now:
- confirm_delete_tag::<name> → requireStaffRole, then proceed.
- TICKET_BUTTON_HANDLERS dispatch → requireStaffRole, then proceed.
- FREE_BUTTON_HANDLERS (open_ticket* panel buttons, cancel_delete_tag)
remain ungated — those are public-facing by design.
requireStaffRole replies ephemerally ("This command is only available
to the support team (<@&role>)") and returns true when the caller
should bail, matching the slash-command behavior.
767 lines
30 KiB
JavaScript
767 lines
30 KiB
JavaScript
/**
|
||
* Button interaction handlers and the ticket-creation modal submit.
|
||
*
|
||
* 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 {
|
||
ChannelType,
|
||
ActionRowBuilder,
|
||
ButtonBuilder,
|
||
ButtonStyle,
|
||
AttachmentBuilder,
|
||
EmbedBuilder,
|
||
MessageFlags,
|
||
PermissionFlagsBits,
|
||
ModalBuilder,
|
||
TextInputBuilder,
|
||
TextInputStyle
|
||
} = require('discord.js');
|
||
const { mongoose } = require('../db-connection');
|
||
const { CONFIG } = require('../config');
|
||
const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, cleanupEmptyOverflowCategory, checkTicketCreationRateLimit, toDiscordSafeName } = require('../services/tickets');
|
||
const { sendTicketClosedEmail } = require('../services/gmail');
|
||
const { getTicketActionRow } = require('../utils/ticketComponents');
|
||
const { sanitizeEmbedText, truncateEmbedDescription } = require('../utils');
|
||
const { enqueueRename, enqueueSend } = require('../services/channelQueue');
|
||
const { runEscalation, runDeescalation } = require('./commands');
|
||
const { pendingCloses } = require('./pendingCloses');
|
||
const { addMemberToStaffThread, createStaffThread } = require('../services/staffThread');
|
||
const { pinMessage } = require('../services/pinMessage');
|
||
const { logError, logTicketEvent } = require('../services/debugLog');
|
||
const { findTicketForChannel, runDeferred } = require('./sharedHelpers');
|
||
const { requireStaffRole } = require('./commands/helpers');
|
||
|
||
const Ticket = mongoose.model('Ticket');
|
||
const Transcript = mongoose.model('Transcript');
|
||
const Tag = mongoose.model('Tag');
|
||
|
||
// ============================================================
|
||
// Free-standing button handlers (no ticket lookup)
|
||
// ============================================================
|
||
|
||
/**
|
||
* Open-ticket panel button (any of `open_ticket`, `open_ticket_thread`,
|
||
* `open_ticket_channel`). Shows the ticket-creation modal.
|
||
*/
|
||
async function handleOpenTicketModal(interaction) {
|
||
const modalCustomId = interaction.customId === 'open_ticket'
|
||
? 'ticket_modal'
|
||
: interaction.customId === 'open_ticket_thread'
|
||
? 'ticket_modal_thread'
|
||
: 'ticket_modal_channel';
|
||
return interaction.showModal(buildOpenTicketModal(modalCustomId));
|
||
}
|
||
|
||
function buildOpenTicketModal(modalCustomId) {
|
||
const modal = new ModalBuilder()
|
||
.setCustomId(modalCustomId)
|
||
.setTitle('Please Enter Your Information');
|
||
|
||
const emailInput = new TextInputBuilder()
|
||
.setCustomId('ticket_email')
|
||
.setLabel('Account Email:')
|
||
.setStyle(TextInputStyle.Short)
|
||
.setPlaceholder('Example: broccoli@indifferentbroccoli.com')
|
||
.setRequired(true)
|
||
.setMaxLength(100);
|
||
|
||
const gameInput = new TextInputBuilder()
|
||
.setCustomId('ticket_game')
|
||
.setLabel('What game do you need help with?')
|
||
.setStyle(TextInputStyle.Short)
|
||
.setPlaceholder('Example: Project Zomboid, Minecraft')
|
||
.setRequired(true)
|
||
.setMaxLength(100);
|
||
|
||
const descriptionInput = new TextInputBuilder()
|
||
.setCustomId('ticket_description')
|
||
.setLabel('What do you need help with?')
|
||
.setStyle(TextInputStyle.Paragraph)
|
||
.setPlaceholder("Example: I can't connect to my server.")
|
||
.setRequired(true)
|
||
.setMaxLength(1000);
|
||
|
||
modal.addComponents(
|
||
new ActionRowBuilder().addComponents(emailInput),
|
||
new ActionRowBuilder().addComponents(gameInput),
|
||
new ActionRowBuilder().addComponents(descriptionInput)
|
||
);
|
||
|
||
return modal;
|
||
}
|
||
|
||
async function handleTagDeleteCancel(interaction) {
|
||
return interaction.update({ content: 'Tag deletion cancelled.', components: [] });
|
||
}
|
||
|
||
async function handleTagDeleteConfirm(interaction) {
|
||
const tagName = interaction.customId.slice('confirm_delete_tag::'.length);
|
||
|
||
try {
|
||
const result = await Tag.deleteOne({ name: tagName });
|
||
if (result.deletedCount === 0) {
|
||
await interaction.update({ content: `❌ Tag "${tagName}" not found.`, components: [] });
|
||
} else {
|
||
await interaction.update({ content: `✅ Tag "${tagName}" deleted successfully.`, components: [] });
|
||
}
|
||
} catch (err) {
|
||
logError('tag-delete-confirm', err, interaction).catch(() => {});
|
||
await interaction.update({ content: '❌ Failed to delete tag.', components: [] });
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// Ticket-scoped button handlers
|
||
// ============================================================
|
||
|
||
/** Toggle claim/unclaim on the current ticket and rewrite the action row. */
|
||
async function handleClaimButton(interaction, ticket) {
|
||
const freshTicket = await Ticket.findOne({ gmailThreadId: ticket.gmailThreadId }).lean();
|
||
if (!freshTicket) {
|
||
return interaction.reply({ content: 'Ticket data missing.', flags: MessageFlags.Ephemeral });
|
||
}
|
||
|
||
const isClaimed = !!freshTicket.claimedBy;
|
||
const claimerLabel = interaction.member?.displayName || interaction.user.username;
|
||
const guild = interaction.guild;
|
||
const isClaimedByMe = freshTicket.claimedBy === claimerLabel;
|
||
|
||
const [row0] = interaction.message.components;
|
||
if (!row0) {
|
||
return interaction.reply({ content: 'No components to update.', flags: MessageFlags.Ephemeral });
|
||
}
|
||
|
||
const row = ActionRowBuilder.from(row0);
|
||
const [btnClose, btnClaim] = row.components;
|
||
if (!btnClose || !btnClaim) {
|
||
return interaction.reply({ content: 'Buttons missing.', flags: MessageFlags.Ephemeral });
|
||
}
|
||
|
||
if (isClaimed && !isClaimedByMe && !CONFIG.ALLOW_CLAIM_OVERWRITE) {
|
||
return interaction.reply({
|
||
content: `This ticket is already claimed by **${freshTicket.claimedBy}**.`,
|
||
flags: MessageFlags.Ephemeral
|
||
});
|
||
}
|
||
|
||
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(
|
||
{ gmailThreadId: freshTicket.gmailThreadId },
|
||
{ $set: { claimedBy: claimerLabel, claimerId: interaction.user.id } }
|
||
);
|
||
freshTicket.claimedBy = claimerLabel;
|
||
freshTicket.claimerId = interaction.user.id;
|
||
|
||
const claimerEmoji = CONFIG.STAFF_EMOJIS[interaction.user.id] || CONFIG.CLAIMER_EMOJI_FALLBACK;
|
||
const creatorNickname = await resolveCreatorNickname(guild, freshTicket);
|
||
const state = freshTicket.escalated ? 'escalated-claimed' : 'claimed';
|
||
enqueueRename(interaction.channel, makeTicketName(state, freshTicket, creatorNickname, claimerEmoji))
|
||
.catch(err => logError('rename', err).catch(() => {}));
|
||
|
||
btnClose
|
||
.setCustomId('close_ticket')
|
||
.setLabel(CONFIG.BUTTON_LABEL_CLOSE)
|
||
.setEmoji(CONFIG.BUTTON_EMOJI_CLOSE)
|
||
.setStyle(ButtonStyle.Secondary)
|
||
.setDisabled(false);
|
||
|
||
btnClaim
|
||
.setCustomId('claim_ticket')
|
||
.setEmoji(CONFIG.BUTTON_EMOJI_UNCLAIM)
|
||
.setStyle(ButtonStyle.Secondary)
|
||
.setDisabled(false)
|
||
.setLabel(`Unclaim (${claimerLabel})`);
|
||
|
||
await interaction.update({ components: [row] });
|
||
|
||
const claimText = CONFIG.TICKET_CLAIMED_MESSAGE
|
||
.replace(/\{staff_mention\}/g, interaction.user.toString())
|
||
.replace(/\{staff_name\}/g, interaction.member?.displayName || interaction.user.username);
|
||
const claimEmbed = new EmbedBuilder()
|
||
.setTitle('✅ Ticket Claimed')
|
||
.setDescription(claimText)
|
||
.setColor(CONFIG.EMBED_COLOR_CLAIMED)
|
||
.setFooter({ text: `Claimed by ${claimerLabel}` });
|
||
await interaction.followUp({ embeds: [claimEmbed] });
|
||
|
||
await addMemberToStaffThread(interaction.channel, interaction.user.id).catch(() => {});
|
||
}
|
||
|
||
async function applyUnclaim(interaction, freshTicket, row, btnClose, btnClaim, claimerLabel, guild) {
|
||
await Ticket.updateOne(
|
||
{ gmailThreadId: freshTicket.gmailThreadId },
|
||
{ $set: { claimedBy: null, claimerId: null } }
|
||
);
|
||
freshTicket.claimedBy = null;
|
||
freshTicket.claimerId = null;
|
||
|
||
const creatorNickname = await resolveCreatorNickname(guild, freshTicket);
|
||
const state = (freshTicket.escalationTier ?? 0) >= 1 ? 'escalated' : 'unclaimed';
|
||
enqueueRename(interaction.channel, makeTicketName(state, freshTicket, creatorNickname))
|
||
.catch(err => logError('rename', err).catch(() => {}));
|
||
|
||
btnClose
|
||
.setCustomId('close_ticket')
|
||
.setLabel(CONFIG.BUTTON_LABEL_CLOSE)
|
||
.setEmoji(CONFIG.BUTTON_EMOJI_CLOSE)
|
||
.setStyle(ButtonStyle.Secondary)
|
||
.setDisabled(false);
|
||
|
||
btnClaim
|
||
.setCustomId('claim_ticket')
|
||
.setEmoji(CONFIG.BUTTON_EMOJI_CLAIM)
|
||
.setStyle(ButtonStyle.Secondary)
|
||
.setDisabled(false)
|
||
.setLabel(CONFIG.BUTTON_LABEL_CLAIM);
|
||
|
||
await interaction.update({ components: [row] });
|
||
|
||
const unclaimText = CONFIG.TICKET_UNCLAIMED_MESSAGE
|
||
.replace(/\{staff_mention\}/g, interaction.user.toString())
|
||
.replace(/\{staff_name\}/g, interaction.member?.displayName || interaction.user.username);
|
||
const unclaimEmbed = new EmbedBuilder()
|
||
.setTitle('🔓 Ticket Unclaimed')
|
||
.setDescription(unclaimText)
|
||
.setColor(0x808080)
|
||
.setFooter({ text: `Unclaimed by ${claimerLabel}` });
|
||
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 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.', flags: MessageFlags.Ephemeral });
|
||
}
|
||
|
||
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;
|
||
|
||
// Lazy require — broccolini-discord re-exports trackTimeout and we'd otherwise cycle.
|
||
const { trackTimeout } = require('../broccolini-discord');
|
||
const timerId = trackTimeout(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.', flags: MessageFlags.Ephemeral });
|
||
}
|
||
|
||
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)],
|
||
flags: MessageFlags.Ephemeral
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 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}.`, flags: MessageFlags.Ephemeral });
|
||
}
|
||
|
||
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.`,
|
||
flags: MessageFlags.Ephemeral
|
||
});
|
||
}
|
||
|
||
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.', flags: MessageFlags.Ephemeral });
|
||
}
|
||
|
||
await runDeferred(interaction, 'deescalate',
|
||
() => runDeescalation(interaction, ticket),
|
||
{ flags: MessageFlags.Ephemeral }
|
||
);
|
||
}
|
||
|
||
// ============================================================
|
||
// 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();
|
||
|
||
try {
|
||
await interaction.update({ content: 'Archiving and closing...', components: [] });
|
||
} catch {
|
||
// Already acknowledged – fall back to editReply
|
||
await interaction.editReply({ content: 'Archiving and closing...', components: [] }).catch(() => {});
|
||
}
|
||
|
||
try {
|
||
const channelName = interaction.channel.name;
|
||
const transcriptText = await buildTranscriptText(interaction.channel, ticket);
|
||
const file = new AttachmentBuilder(Buffer.from(transcriptText), {
|
||
name: `transcript-${channelName}.txt`
|
||
});
|
||
|
||
const openedStr = formatDateForTranscript(ticket.createdAt);
|
||
const closedStr = formatDateForTranscript(closedAt);
|
||
const transcriptContent = renderTranscriptHeader(channelName, ticket.senderEmail, openedStr, closedStr);
|
||
|
||
await enqueueSend(interaction.channel, CONFIG.DISCORD_CLOSE_MESSAGE);
|
||
|
||
let transcriptMsg = null;
|
||
const transcriptChan = await interaction.client.channels
|
||
.fetch(CONFIG.TRANSCRIPT_CHANNEL_ID)
|
||
.catch(() => null);
|
||
if (transcriptChan) {
|
||
transcriptMsg = await enqueueSend(transcriptChan, {
|
||
content: transcriptContent,
|
||
files: [file]
|
||
});
|
||
}
|
||
|
||
// Optionally DM the transcript to the ticket creator. Many users have
|
||
// server-member DMs disabled; gated to avoid 50007 noise. Discord-origin
|
||
// tickets only.
|
||
if (CONFIG.TRANSCRIPT_DM_TO_CREATOR && ticket.gmailThreadId?.startsWith('discord-')) {
|
||
await dmTranscriptToCreator(interaction.client, ticket, channelName, transcriptText, openedStr, closedStr);
|
||
}
|
||
|
||
await postCloseLogEntry(interaction, ticket, channelName);
|
||
|
||
const closerDisplayName = interaction.member?.displayName || interaction.user.username;
|
||
if (sendEmail && !ticket.gmailThreadId?.startsWith('discord-')) {
|
||
await sendTicketClosedEmail(ticket, closerDisplayName, interaction.user.id);
|
||
}
|
||
|
||
// $unset welcomeMessageId so a future reopen on this thread doesn't carry
|
||
// a stale message ID pointing into the now-deleted channel.
|
||
await Ticket.updateOne(
|
||
{ gmailThreadId: ticket.gmailThreadId },
|
||
{ $set: { discordThreadId: null, status: 'closed' }, $unset: { welcomeMessageId: '' } }
|
||
);
|
||
|
||
if (transcriptMsg?.id) {
|
||
await Transcript.create({
|
||
gmailThreadId: ticket.gmailThreadId,
|
||
transcriptMessageId: transcriptMsg.id,
|
||
createdAt: new Date()
|
||
});
|
||
}
|
||
|
||
const parentCatId = ticket.parentCategoryId;
|
||
const guildRef = interaction.guild;
|
||
|
||
// Lazy require — same cycle reason as in handleConfirmCloseRequest above.
|
||
const { trackTimeout } = require('../broccolini-discord');
|
||
trackTimeout(setTimeout(() => interaction.channel.delete().catch(() => {}), 5000));
|
||
trackTimeout(setTimeout(() => {
|
||
if (parentCatId && guildRef) {
|
||
cleanupEmptyOverflowCategory(guildRef, parentCatId, CONFIG.TICKET_CATEGORY_NAME).catch(() => {});
|
||
}
|
||
}, 6000));
|
||
} catch (e) {
|
||
console.error('Close ticket error:', e);
|
||
}
|
||
}
|
||
|
||
/** Render the last 100 messages of a channel as a plaintext transcript. */
|
||
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) {
|
||
// Prefer ticket.creatorId (stored on creation). Fall back to legacy parsing for
|
||
// pre-creatorId modal tickets only — split-pop returns the wrong value for
|
||
// discord-msg-* tickets (it yields the message ID, not the user ID).
|
||
const creatorId = ticket.creatorId
|
||
|| (ticket.gmailThreadId.startsWith('discord-msg-') ? null : ticket.gmailThreadId.split('-').pop());
|
||
if (!creatorId) return;
|
||
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.creatorId
|
||
|| (ticket.gmailThreadId.startsWith('discord-msg-') ? null : ticket.gmailThreadId.split('-').pop());
|
||
let creator = null;
|
||
if (creatorId) {
|
||
creator = await interaction.client.users.fetch(creatorId).catch(() => null);
|
||
}
|
||
logMsg = creator
|
||
? `Closed ${creator.toString()}'s **${channelName}** by ${closerMention} (${closerDisplayName})`
|
||
: `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) {
|
||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||
|
||
const email = interaction.fields.getTextInputValue('ticket_email').trim().toLowerCase();
|
||
const game = interaction.fields.getTextInputValue('ticket_game').trim();
|
||
const description = interaction.fields.getTextInputValue('ticket_description');
|
||
const subject = game ? `[${game}] ${description.slice(0, 60)}` : description.slice(0, 80);
|
||
const priority = CONFIG.PRIORITY_ENABLED ? CONFIG.DEFAULT_PRIORITY : 'normal';
|
||
|
||
const rateLimit = checkTicketCreationRateLimit(interaction.user.id);
|
||
if (!rateLimit.allowed) {
|
||
const mins = Math.ceil((rateLimit.retryAfterMs || 0) / 60000);
|
||
return interaction.editReply(`You can only create ${CONFIG.RATE_LIMIT_TICKETS_PER_USER} ticket(s) per ${CONFIG.RATE_LIMIT_WINDOW_MINUTES} minutes. Try again in ${mins} minute(s).`);
|
||
}
|
||
|
||
try {
|
||
const guild = interaction.guild;
|
||
const lastTicket = await Ticket.findOne().sort({ ticketNumber: -1 }).select('ticketNumber').lean();
|
||
const ticketNumber = (lastTicket?.ticketNumber || 0) + 1;
|
||
|
||
const creatorNickname = interaction.member?.displayName || interaction.user.username;
|
||
const unclaimedName = toDiscordSafeName(`unclaimed-${creatorNickname}-${ticketNumber}`);
|
||
|
||
let parentCategoryIdForTicket;
|
||
try {
|
||
parentCategoryIdForTicket = await getOrCreateTicketCategory(
|
||
guild,
|
||
CONFIG.DISCORD_TICKET_CATEGORY_ID,
|
||
CONFIG.TICKET_CATEGORY_NAME
|
||
);
|
||
} catch (err) {
|
||
console.error('getOrCreateTicketCategory (ticket modal):', err);
|
||
return interaction.editReply('Discord ticket category could not be resolved. Contact an administrator.');
|
||
}
|
||
|
||
let channel;
|
||
try {
|
||
// Initial permissionOverwrites on guild.channels.create are safe-by-construction:
|
||
// the channel doesn't exist yet, so there's no in-flight rename/send/move to race
|
||
// against. Any *subsequent* mutation on this channel (add/remove user, move,
|
||
// topic, rename) must go through services/channelQueue.js.
|
||
channel = await guild.channels.create({
|
||
name: unclaimedName,
|
||
type: ChannelType.GuildText,
|
||
parent: parentCategoryIdForTicket,
|
||
permissionOverwrites: [
|
||
{ id: guild.id, deny: [PermissionFlagsBits.ViewChannel] },
|
||
{
|
||
id: interaction.user.id,
|
||
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
|
||
},
|
||
{
|
||
id: CONFIG.ROLE_ID_TO_PING,
|
||
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
|
||
}
|
||
]
|
||
});
|
||
} catch (err) {
|
||
console.error('guild.channels.create (ticket modal):', err);
|
||
return interaction.editReply('Failed to create ticket channel. Contact an administrator.');
|
||
}
|
||
|
||
const gmailThreadId = `discord-${Date.now()}-${interaction.user.id}`;
|
||
const now = new Date();
|
||
await Ticket.create({
|
||
gmailThreadId,
|
||
discordThreadId: channel.id,
|
||
senderEmail: email,
|
||
subject,
|
||
game: game || null,
|
||
createdAt: now,
|
||
status: 'open',
|
||
ticketNumber,
|
||
priority,
|
||
lastActivity: now,
|
||
creatorId: interaction.user.id,
|
||
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 welcomeEmbed = new EmbedBuilder()
|
||
.setTitle("We got your ticket.")
|
||
.setDescription("We'll be with you as soon as possible.")
|
||
.setColor(5763719)
|
||
.setThumbnail("https://indifferentbroccoli.com/img/broccoli_shadow_square.png");
|
||
|
||
const infoEmbed = new EmbedBuilder()
|
||
.setColor(5763719)
|
||
.setDescription(truncateEmbedDescription(
|
||
`**Account Email:**\n\`\`\`\n${sanitizeEmbedText(email)}\n\`\`\`\n` +
|
||
`**Game:**\n\`\`\`\n${sanitizeEmbedText(game) || "Not specified"}\n\`\`\`\n` +
|
||
`**What do you need help with?**\n\`\`\`\n${sanitizeEmbedText(descTrimmed)}\n\`\`\``
|
||
));
|
||
|
||
const resourcesEmbed = new EmbedBuilder()
|
||
.setTitle("We're ~~happy~~ indifferent to help. :indifferentbroccoli:")
|
||
.setDescription("Please feel free to add any additional information to the ticket, including recent changes to the server, if any.")
|
||
.setColor(5763719)
|
||
.addFields(
|
||
{ name: "Check out our wiki for guides:", value: "[Indifferent Broccolipedia](https://wiki.indifferentbroccoli.com)", inline: false }
|
||
)
|
||
.setFooter({ text: "indifferent broccoli tickets (:|)", iconURL: "https://i.ibb.co/sJdytfFM/Untitled-design-6.png" });
|
||
|
||
const actionRow = getTicketActionRow({ escalationTier: 0 });
|
||
|
||
let welcomeMsg;
|
||
try {
|
||
welcomeMsg = await enqueueSend(channel, {
|
||
content: `Hey There ${interaction.user} 🥦`,
|
||
embeds: [welcomeEmbed, infoEmbed, resourcesEmbed],
|
||
components: [actionRow]
|
||
});
|
||
|
||
await Ticket.updateOne(
|
||
{ discordThreadId: channel.id },
|
||
{ $set: { welcomeMessageId: welcomeMsg.id } }
|
||
);
|
||
} catch (err) {
|
||
console.error('welcomeMessageId-save', err);
|
||
}
|
||
return welcomeMsg;
|
||
}
|
||
|
||
// ============================================================
|
||
// Dispatch tables
|
||
// ============================================================
|
||
|
||
/** Buttons that don't depend on a ticket-bound channel. */
|
||
const FREE_BUTTON_HANDLERS = {
|
||
open_ticket: handleOpenTicketModal,
|
||
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>`).
|
||
// Mutates the Tag collection — staff only.
|
||
if (customId.startsWith('confirm_delete_tag::')) {
|
||
if (await requireStaffRole(interaction)) return;
|
||
return handleTagDeleteConfirm(interaction);
|
||
}
|
||
|
||
// FREE_BUTTON_HANDLERS are public-facing: open_ticket* (panel buttons,
|
||
// anyone can open a ticket) and cancel_delete_tag (no-op cancel).
|
||
const freeHandler = FREE_BUTTON_HANDLERS[customId];
|
||
if (freeHandler) return freeHandler(interaction);
|
||
|
||
const ticketHandler = TICKET_BUTTON_HANDLERS[customId];
|
||
if (!ticketHandler) return;
|
||
|
||
// Every TICKET_BUTTON_HANDLERS entry mutates ticket state
|
||
// (claim/close/confirm_close*/cancel_close/escalate*/deescalate). The slash
|
||
// command dispatcher in handlers/commands/index.js gates these via
|
||
// requireStaffRole; the button dispatcher must do the same — non-staff
|
||
// members with view access to the ticket channel (creator, /add'd users)
|
||
// could otherwise click Claim, Escalate, Close, etc.
|
||
if (await requireStaffRole(interaction)) return;
|
||
|
||
const ticket = await findTicketForChannel(
|
||
interaction,
|
||
'This channel is not linked to a ticket, or the ticket could not be found.'
|
||
);
|
||
if (!ticket) return;
|
||
return ticketHandler(interaction, ticket);
|
||
}
|
||
|
||
module.exports = { handleButton, handleTicketModal };
|