Files
broccolini-bot/handlers/buttons.js
indifferentketchup cdf85f6364 audit week 1: creator ID tracking, channel-queue migration, deprecation cleanup
QUAL-006  store ticket.creatorId on creation; legacy split-pop returned the
          message ID for discord-msg-* tickets, breaking transcript DM, close
          log, and channel rename for context-menu-created tickets. Adds the
          field to the Ticket schema and writes a one-shot backfill script
          (scripts/backfill-creatorId.js, dry-run by default).

QUEUE-001 add enqueueOverwrite + enqueueTopic to services/channelQueue.js
          (chain on renameChains alongside enqueueMove). Migrate handleAdd /
          handleRemove / handleMove / handleTopic so permissionOverwrites,
          setParent, and setTopic no longer race pending renames or sends.
          handleMove now uses the existing enqueueMove. Initial overwrites in
          handleTicketModal stay inline; channel doesn't exist yet so no race.

DISCORD-001 replace ephemeral: true with flags: MessageFlags.Ephemeral across
            broccolini-discord.js, handlers/sharedHelpers.js, handlers/buttons.js,
            handlers/commands.js. runDeferred opts now take { flags } directly.

SEC-003   /gmailpoll min interval is 30s. Drop the 5s/10s slash-command
          choices and clamp Math.max(30000, ms) in handleGmailPoll for
          defense in depth.

QUAL-001  upgrade silent .catch(() => {}) on the lastActivity updateOne in
          handlers/messages.js to log via logError, so transient Mongo errors
          surface in the debug channel instead of disappearing.

QUAL-002  drop await from logError/logWarn calls in services/staffThread.js
          and services/pinMessage.js — fire-and-forget per CLAUDE.md hard rule.

QUAL-003  wrap stray setTimeouts (handleConfirmCloseRequest force-close timer,
          runFinalClose channel-delete + overflow-cleanup, checkAutoClose
          delete-after-email) in trackTimeout via lazy require so they clear
          on shutdown.
2026-05-08 20:19:14 +00:00

752 lines
30 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,
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.', 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);
}
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;
// 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>`).
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 };