Files
broccolini-bot/handlers/buttons.js
indifferentketchup e3b3b8d48c refactor handleButton into a dispatch table
Each customId now maps to a named handler in one of two tables:
FREE_BUTTON_HANDLERS (open-ticket panel, tag-delete cancel — no ticket
lookup) or TICKET_BUTTON_HANDLERS (anything fired inside a ticket channel
— the dispatcher does the lookup once before delegating). The dynamic
`confirm_delete_tag::*` id is matched by prefix.

To find a button's logic, search handle<Name>Button or handleTagDelete*.

Other cleanups in the same pass:
- Move findTicketForChannel and runDeferred from handlers/commands.js to
  the new handlers/sharedHelpers.js so both files share one source of
  truth. runDeferred now also calls logError(verb, ...) — was logged ad
  hoc in buttons.js, missing in commands.js. Strictly additive.
- Hoist three inline `require('../services/...')` calls (staffThread,
  pinMessage, debugLog) to top imports.
- Collapse escalate_to_tier2 and escalate_to_tier3 into one
  handleEscalateButton(interaction, ticket) that derives the tier from
  customId. Same for confirm_close / confirm_close_with_email /
  confirm_close_no_email — one handleConfirmCloseRequest deriving
  sendEmail from customId.
- Decompose the 156-line handleConfirmClose into runFinalClose +
  buildTranscriptText + formatDateForTranscript + renderTranscriptHeader
  + dmTranscriptToCreator + postCloseLogEntry. Each piece is testable in
  isolation.
- Decompose handleClaim into applyClaim + applyUnclaim.
- Extract buildOpenTicketModal() and postTicketWelcomeEmbeds() so the
  ticket-creation modal flow is readable top-to-bottom.

No behavior change. handleButton + handleTicketModal exports preserved;
24/24 modules load clean (sharedHelpers.js is the new one).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 18:57:43 +00:00

736 lines
28 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,
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 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.', ephemeral: true });
}
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.', ephemeral: true });
}
const row = ActionRowBuilder.from(row0);
const [btnClose, btnClaim] = row.components;
if (!btnClose || !btnClaim) {
return interaction.reply({ content: 'Buttons missing.', ephemeral: true });
}
if (isClaimed && !isClaimedByMe && !CONFIG.ALLOW_CLAIM_OVERWRITE) {
return interaction.reply({
content: `This ticket is already claimed by **${freshTicket.claimedBy}**.`,
ephemeral: true
});
}
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.', 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();
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);
}
await Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $set: { discordThreadId: null, status: 'closed' } }
);
if (transcriptMsg?.id) {
await Transcript.create({
gmailThreadId: ticket.gmailThreadId,
transcriptMessageId: transcriptMsg.id,
createdAt: new Date()
});
}
const parentCatId = ticket.parentCategoryId;
const guildRef = interaction.guild;
setTimeout(() => interaction.channel.delete().catch(() => {}), 5000);
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) {
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) {
await interaction.deferReply({ ephemeral: true });
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 {
// 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({
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,
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>`).
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;
return ticketHandler(interaction, ticket);
}
module.exports = { handleButton, handleTicketModal };