Files
broccolini-bot/handlers/buttons.js
indifferentketchup 6a7dee679c Close: make side-effects best-effort so none can abort the commit+delete
runFinalClose ran the transcript archive, creator DM, close log, and closure
email in the same try as the close transition and channel delete, with the
transcript posted *before* the commit. A failure in any of them (notably a
DiscordAPIError 50001 Missing Access when posting the transcript to the archive
channel) aborted the whole close: the customer saw 'ticket closed' but the DB
stayed open and the channel was never deleted.

Rewrite so the close transition + pendingDelete commit FIRST, each side-effect is
individually best-effort via a closeStep wrapper, and scheduleTicketChannelDelete
always runs. finalizeForceClose was already commit-first; wrap its remaining
unguarded archiving send too.
2026-06-05 11:27:45 +00:00

796 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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,
ModalBuilder,
TextInputBuilder,
TextInputStyle
} = require('discord.js');
const { mongoose } = require('../db-connection');
const { CONFIG } = require('../config');
const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, cleanupEmptyOverflowCategory, checkTicketCreationRateLimit, toDiscordSafeName, attemptCloseTransition, scheduleTicketChannelDelete } = require('../services/tickets');
const { sendTicketClosedEmail } = require('../services/gmail');
const { moveThreadToFolder } = require('../services/gmailLabels');
const { getTicketActionRow, ticketChannelOverwrites } = require('../utils/ticketComponents');
const { buildTranscriptText, formatDateForTranscript, renderTranscriptHeader } = require('../services/transcript');
const { sanitizeEmbedText, truncateEmbedDescription, isStaff } = require('../utils');
const { recordAction } = require('../services/staffStats');
const { enqueueRename, enqueueSend } = require('../services/channelQueue');
const { runEscalation, runDeescalation, resolveEscalationCategoryId } = 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, _TicketModel, _recordAction) {
const T = _TicketModel || Ticket;
const record = _recordAction || recordAction;
const result = await T.updateOne(
{ gmailThreadId: freshTicket.gmailThreadId, claimerId: { $ne: interaction.user.id } },
{ $set: { claimedBy: claimerLabel, claimerId: interaction.user.id } }
);
freshTicket.claimedBy = claimerLabel;
freshTicket.claimerId = interaction.user.id;
if (result.modifiedCount === 1) {
record(interaction.user.id, 'claim', {
ticket: freshTicket,
guildId: interaction.guild?.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, 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 categoryId = resolveEscalationCategoryId(ticket, tier);
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));
}
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.
*/
// Run one best-effort close side-effect. A failure is logged but never propagates,
// so it cannot abort the close — the transition and channel delete still happen.
async function closeStep(label, fn) {
try {
await fn();
} catch (e) {
logError(`runFinalClose:${label}`, e).catch(() => {});
}
}
async function runFinalClose(interaction, ticket, sendEmail = true) {
const closedAt = new Date();
const channel = interaction.channel;
const channelName = channel.name;
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(() => {});
}
// Build the transcript up front — it needs the channel's history, before delete.
// Best-effort: a failure here must not block the close.
let transcriptText = null;
await closeStep('buildTranscript', async () => { transcriptText = await buildTranscriptText(channel, ticket); });
// CRITICAL #1 — commit the close and mark pendingDelete (discordThreadId stays
// set for restart recovery). Done BEFORE the fallible side-effects below so none
// of them can leave a "closed"-looking but still-open, undeleted ticket.
let transitioned = false;
let closedTicket = null;
await closeStep('transition', async () => {
({ transitioned, ticket: closedTicket } =
await attemptCloseTransition(ticket.gmailThreadId, { pendingDelete: true }, { welcomeMessageId: '' }));
});
// Customer-facing close notice (best-effort).
await closeStep('closeMessage', () => enqueueSend(channel, CONFIG.DISCORD_CLOSE_MESSAGE));
// Archive the transcript to the transcript channel (best-effort — a Missing
// Access here previously aborted the whole close).
let transcriptMsg = null;
if (transcriptText != null) {
const openedStr = formatDateForTranscript(ticket.createdAt);
const closedStr = formatDateForTranscript(closedAt);
await closeStep('transcriptArchive', async () => {
const transcriptContent = renderTranscriptHeader(channelName, ticket.senderEmail, openedStr, closedStr);
const file = new AttachmentBuilder(Buffer.from(transcriptText), { name: `transcript-${channelName}.txt` });
const transcriptChan = await interaction.client.channels.fetch(CONFIG.TRANSCRIPT_CHANNEL_ID).catch(() => null);
if (transcriptChan) {
transcriptMsg = await enqueueSend(transcriptChan, {
content: transcriptContent, files: [file], allowedMentions: { parse: [] }
});
}
});
// DM the transcript to the creator (Discord-origin tickets only). Best-effort —
// many users have member DMs disabled (50007).
if (CONFIG.TRANSCRIPT_DM_TO_CREATOR && ticket.gmailThreadId?.startsWith('discord-')) {
await closeStep('dmCreator', () =>
dmTranscriptToCreator(interaction.client, ticket, channelName, transcriptText, openedStr, closedStr));
}
}
await closeStep('closeLog', () => postCloseLogEntry(interaction, ticket, channelName));
if (sendEmail && !ticket.gmailThreadId?.startsWith('discord-')) {
const closerDisplayName = interaction.member?.displayName || interaction.user.username;
await closeStep('closeEmail', () => sendTicketClosedEmail(ticket, closerDisplayName, interaction.user.id));
}
// File the email thread into the Resolved folder — non-fatal, email tickets only.
if (!ticket.gmailThreadId?.startsWith('discord-')) {
moveThreadToFolder(ticket.gmailThreadId, 'RESOLVED')
.catch(err => logError('gmailLabels: resolved move', err).catch(() => {}));
}
if (transitioned && closedTicket) {
const closerType = isStaff(interaction.member) ? 'staff' : 'user';
recordAction(interaction.user.id, 'close', {
ticket: closedTicket,
guildId: interaction.guild?.id,
closerType,
resolverId: closedTicket.claimerId ?? null,
wasClaimed: Boolean(closedTicket.claimerId)
});
}
if (transcriptMsg?.id) {
await closeStep('transcriptRecord', () => Transcript.create({
gmailThreadId: ticket.gmailThreadId,
transcriptMessageId: transcriptMsg.id,
createdAt: new Date()
}));
}
// CRITICAL #2 — schedule the channel delete. Always runs, regardless of any
// side-effect failure above.
scheduleTicketChannelDelete(channel, ticket.gmailThreadId);
// Best-effort overflow-category cleanup after the channel is gone.
const parentCatId = ticket.parentCategoryId;
const guildRef = interaction.guild;
// Lazy require — same cycle reason as in handleConfirmCloseRequest above.
const { trackTimeout } = require('../broccolini-discord');
trackTimeout(setTimeout(() => {
if (parentCatId && guildRef) {
cleanupEmptyOverflowCategory(guildRef, parentCatId, CONFIG.TICKET_CATEGORY_NAME).catch(() => {});
}
}, 6000));
}
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, { content: logMsg, allowedMentions: { parse: [] } });
}
// ============================================================
// 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: ticketChannelOverwrites(guild, interaction.user.id)
});
} 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, { content: `📝 ${channel.name} created by ${interaction.user.tag}`, allowedMentions: { parse: [] } });
}
}
} 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
// ============================================================
/**
* Public-facing buttons that don't require a staff role: the panel buttons
* that any member uses to open a ticket. Customer-facing entry points stay
* here. cancel_delete_tag is staff-only and gated separately in handleButton.
*/
const FREE_BUTTON_HANDLERS = {
open_ticket: handleOpenTicketModal,
open_ticket_thread: handleOpenTicketModal,
open_ticket_channel: handleOpenTicketModal
};
/** 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
};
/**
* TICKET_BUTTON_HANDLERS entries that any user with channel access may
* invoke — not just staff. Ticket creators and /add'd users get to close
* their own ticket (with the 60s countdown still in place) and cancel a
* pending close. Claim/escalate/de-escalate stay staff-only.
*/
const PUBLIC_TICKET_BUTTONS = new Set([
'close_ticket',
'confirm_close',
'confirm_close_with_email',
'confirm_close_no_email',
'cancel_close'
]);
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);
}
// Tag-delete cancel: paired with the staff-only delete flow; gate to keep
// the button surface consistent (non-staff can't reach the dialog anyway).
if (customId === 'cancel_delete_tag') {
if (await requireStaffRole(interaction)) return;
return handleTagDeleteCancel(interaction);
}
// FREE_BUTTON_HANDLERS are the public-facing panel buttons (open_ticket*).
// Customers/members must be able to click these to open a ticket.
const freeHandler = FREE_BUTTON_HANDLERS[customId];
if (freeHandler) return freeHandler(interaction);
const ticketHandler = TICKET_BUTTON_HANDLERS[customId];
if (!ticketHandler) return;
// Claim / escalate / de-escalate mutate staff-owned ticket state and stay
// staff-only. Close-related buttons (close_ticket, confirm_close*,
// cancel_close) are public so a ticket creator can close their own ticket;
// the 60s force-close countdown still applies, and the cancel button is
// intentionally visible to anyone in the channel so any party can abort.
if (!PUBLIC_TICKET_BUTTONS.has(customId) && (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, runFinalClose, applyClaim };