Files
broccolini-bot/handlers/buttons.js
indifferentketchup 3c13e55dad audit week 3 quality batch: QUAL-004/005/007/008/010 + SEC-002
QUAL-004 handlers/messages.js — DM-on-customer-reply now reads
guild.members.cache.get(claimerId) first and only falls back to
guild.members.fetch on cache miss. Avoids a REST round-trip per non-staff
reply on busy tickets. GuildMembers intent already keeps the cache warm.

QUAL-005 handlers/buttons.js (runFinalClose) + handlers/commands/close.js
(finalizeForceClose) — close paths now $unset welcomeMessageId alongside
the status: 'closed' write. Stops a stale message-ID from carrying into a
future reopen on the same Gmail thread, where escalation's "edit welcome
buttons" path would silently fail trying to fetch a message in a deleted
channel.

QUAL-007 services/configPersistence.js — writeEnvFile mismatch error now
includes the missing/extra key sets, not just count vs count. Saves the
operator from guessing which key vanished after a partial write.

QUAL-008 utils.js stripEmailQuotes — replaced order-dependent first-match
loop with an earliest-match-across-all-markers scan. The previous code
could truncate at a late "_____" signature underline even when an earlier
"On X wrote:" reply header was the real cutoff. New test in
tests/utils.test.js exercises the dual-marker case.

QUAL-010 broccolini-discord.js — moved `let httpServer / internalServer /
appReady` declarations from after the ready handler to before it. Same
runtime behavior (module-load completes before ready fires asynchronously),
but the read order now matches the assignment order.

SEC-002 routes/internalApi.js — POST /restart now goes through a tighter
2/min limiter on top of the shared 10/min internalLimiter. Defense in
depth in case INTERNAL_API_SECRET ever leaks; an attacker with the secret
can no longer crash-loop the container.

Skipped: QUAL-009 (re-checked the regex; ^\s*\n* → \n is already
idempotent — the audit finding was incorrect).

vitest run: 88/88 (one new test for QUAL-008).
2026-05-08 20:46:04 +00:00

754 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);
}
// $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>`).
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 };