Files
broccolini-bot/handlers/buttons.js
indifferentketchup c5d7539677 staff notifications
2026-04-06 23:53:32 -05:00

717 lines
26 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 claim, close, priority, tag delete,
* open-ticket panel button, and ticket_modal submission.
*/
const {
ChannelType,
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
AttachmentBuilder,
EmbedBuilder,
PermissionFlagsBits,
ModalBuilder,
TextInputBuilder,
TextInputStyle
} = require('discord.js');
const { mongoose } = require('../db-connection');
const { CONFIG } = require('../config');
const { canRename, makeTicketName, resolveCreatorNickname, minutesFromMs, getOrCreateTicketCategory, cleanupEmptyOverflowCategory, createDiscordTicketAsThread, checkTicketCreationRateLimit, getSenderLocal, toDiscordSafeName } = require('../services/tickets');
const { sendTicketClosedEmail } = require('../services/gmail');
const { getTicketActionRow } = require('../utils/ticketComponents');
const { setEmailRouting } = require('../services/guildSettings');
const { enqueueRename } = require('../services/channelQueue');
const { runEscalation, runDeescalation } = require('./commands');
const { trackInteraction, trackError } = require('./analytics');
const Ticket = mongoose.model('Ticket');
const Transcript = mongoose.model('Transcript');
const Tag = mongoose.model('Tag');
const User = mongoose.model('User');
/**
* Main button/modal handler called from interactionCreate.
*/
async function handleButton(interaction) {
// --- "Open Ticket" panel buttons → show modal ---
if (interaction.customId === 'open_ticket' || interaction.customId === 'open_ticket_thread' || interaction.customId === 'open_ticket_channel') {
const modalCustomId = interaction.customId === 'open_ticket'
? 'ticket_modal'
: interaction.customId === 'open_ticket_thread'
? 'ticket_modal_thread'
: 'ticket_modal_channel';
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 await interaction.showModal(modal);
}
// --- Email routing (no ticket required) ---
if (interaction.customId === 'email_routing_thread' || interaction.customId === 'email_routing_category') {
const value = interaction.customId === 'email_routing_thread' ? 'thread' : 'category';
try {
await setEmailRouting(interaction.guild.id, value);
const label = value === 'thread' ? '**threads**' : '**channels in a category**';
await interaction.reply({
content: `Done. New email tickets will now be created as ${label}.`,
ephemeral: true
});
} catch (err) {
trackError('email-routing-button', err, interaction);
await interaction.reply({
content: 'Failed to update email routing.',
ephemeral: true
}).catch(() => {});
}
return;
}
// --- Ticket-scoped buttons (need ticket lookup) ---
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
if (!ticket) {
return interaction.reply({
content: 'This channel is not linked to a ticket, or the ticket could not be found.',
ephemeral: true
});
}
// --- CLAIM / UNCLAIM ---
if (interaction.customId === 'claim_ticket') {
return handleClaim(interaction, ticket);
}
// --- CLOSE ---
if (interaction.customId === 'close_ticket') {
const confirmRow = new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setCustomId('confirm_close')
.setLabel('Confirm Close')
.setStyle(ButtonStyle.Danger),
new ButtonBuilder()
.setCustomId('cancel_close')
.setLabel('Cancel')
.setStyle(ButtonStyle.Secondary)
);
return interaction.reply({
content: 'Are you sure you want to close this ticket?',
components: [confirmRow]
});
}
if (interaction.customId === 'confirm_close') {
return handleConfirmClose(interaction, ticket);
}
if (interaction.customId === 'cancel_close') {
return interaction.update({ content: 'Close cancelled.', components: [] });
}
// --- ESCALATE (prompt for tier 2 or 3) ---
if (interaction.customId === 'escalate_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 escalateButtons = [];
if (currentTier < 1) {
escalateButtons.push(
new ButtonBuilder()
.setCustomId('escalate_to_tier2')
.setLabel('To Tier 2')
.setStyle(ButtonStyle.Secondary)
);
}
if (currentTier < 2) {
escalateButtons.push(
new ButtonBuilder()
.setCustomId('escalate_to_tier3')
.setLabel('To Tier 3')
.setStyle(ButtonStyle.Secondary)
);
}
const choiceRow = new ActionRowBuilder().addComponents(escalateButtons);
return interaction.reply({
content: 'Escalate to which tier?',
components: [choiceRow],
ephemeral: true
});
}
if (interaction.customId === 'escalate_to_tier2') {
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
if (currentTier >= 1) {
return interaction.reply({ content: 'This ticket is already at tier 2.', ephemeral: true });
}
const categoryId = ticket.gmailThreadId.startsWith('discord-')
? (CONFIG.DISCORD_ESCALATED2_CHANNEL_ID || CONFIG.DISCORD_ESCALATED_CATEGORY_ID)
: (CONFIG.EMAIL_ESCALATED2_CHANNEL_ID || CONFIG.EMAIL_ESCALATED_CATEGORY_ID);
if (!categoryId && !interaction.channel.isThread()) {
return interaction.reply({ content: 'Tier 2 (ESCALATED2) is not configured for this ticket type.', ephemeral: true });
}
try {
await interaction.deferReply();
await runEscalation(interaction, ticket, 1, 'Escalated via button (Tier 2)');
} catch (err) {
trackError('escalate-button-tier2', err, interaction);
await interaction.editReply({ content: 'Failed to escalate to tier 2.' }).catch(() =>
interaction.followUp({ content: 'Failed to escalate to tier 2.', ephemeral: true }).catch(() => {})
);
}
return;
}
if (interaction.customId === 'escalate_to_tier3') {
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
if (currentTier >= 2) {
return interaction.reply({ content: 'This ticket is already at tier 3.', ephemeral: true });
}
const categoryId = ticket.gmailThreadId.startsWith('discord-')
? CONFIG.DISCORD_ESCALATED3_CHANNEL_ID
: CONFIG.EMAIL_ESCALATED3_CHANNEL_ID;
if (!categoryId && !interaction.channel.isThread()) {
return interaction.reply({ content: 'Tier 3 (ESCALATED3) is not configured for this ticket type.', ephemeral: true });
}
try {
await interaction.deferReply();
await runEscalation(interaction, ticket, 2, 'Escalated via button (Tier 3)');
} catch (err) {
trackError('escalate-button-tier3', err, interaction);
await interaction.editReply({ content: 'Failed to escalate to tier 3.' }).catch(() =>
interaction.followUp({ content: 'Failed to escalate to tier 3.', ephemeral: true }).catch(() => {})
);
}
return;
}
// --- DEESCALATE ---
if (interaction.customId === 'deescalate_ticket') {
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
if (currentTier === 0) {
return interaction.reply({ content: 'This ticket is not escalated.', ephemeral: true });
}
try {
await interaction.deferReply({ ephemeral: true });
await runDeescalation(interaction, ticket);
} catch (err) {
trackError('deescalate-button', err, interaction);
await interaction.editReply({ content: 'Failed to deescalate this ticket.' }).catch(() =>
interaction.followUp({ content: 'Failed to deescalate this ticket.', ephemeral: true }).catch(() => {})
);
}
return;
}
// --- TAG DELETE CONFIRM ---
if (interaction.customId.startsWith('confirm_delete_tag::')) {
trackInteraction('buttons', 'confirm-delete-tag', interaction.user.tag);
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) {
trackError('tag-delete-confirm', err, interaction);
await interaction.update({
content: '❌ Failed to delete tag.',
components: []
});
}
}
if (interaction.customId === 'cancel_delete_tag') {
return interaction.update({ content: 'Tag deletion cancelled.', components: [] });
}
// Priority is set via /priority slash command only; no priority buttons in tickets.
}
// --- CLAIM LOGIC ---
async function handleClaim(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
});
}
if (!isClaimed || (isClaimed && !isClaimedByMe && CONFIG.ALLOW_CLAIM_OVERWRITE)) {
await Ticket.updateOne(
{ gmailThreadId: freshTicket.gmailThreadId },
{ $set: { claimedBy: claimerLabel, claimerId: interaction.user.id } }
);
freshTicket.claimedBy = claimerLabel;
freshTicket.claimerId = interaction.user.id;
// Resolve claimerEmoji from STAFF_EMOJIS map (fallback to CLAIMER_EMOJI_FALLBACK)
const claimerEmoji = CONFIG.STAFF_EMOJIS.get(interaction.user.id) || CONFIG.CLAIMER_EMOJI_FALLBACK;
const creatorNickname = await resolveCreatorNickname(guild, freshTicket);
const renameInfo = await canRename(freshTicket);
if (renameInfo.ok) {
const state = freshTicket.escalated ? 'escalated-claimed' : 'claimed';
const newName = makeTicketName(state, freshTicket, creatorNickname, claimerEmoji);
try {
await enqueueRename(interaction.channel, newName);
} catch (e) {
console.error('Rename error (claim):', e);
}
} else {
const unlockAtMs = Date.now() + renameInfo.waitMs;
const unlockAtUnix = Math.floor(unlockAtMs / 1000);
await interaction.channel.send(
`Channel renamed too quickly. Try again <t:${unlockAtUnix}:R>.`
);
}
const baseLabel = `Unclaim (${claimerLabel})`;
const label = renameInfo.ok
? baseLabel
: `${baseLabel} rename in ${minutesFromMs(renameInfo.waitMs)}m`;
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(label);
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] });
} else {
// Unclaim
await Ticket.updateOne(
{ gmailThreadId: freshTicket.gmailThreadId },
{ $set: { claimedBy: null, claimerId: null, staffChannelId: null } }
);
freshTicket.claimedBy = null;
freshTicket.claimerId = null;
freshTicket.staffChannelId = null;
const creatorNicknameUnclaim = await resolveCreatorNickname(guild, freshTicket);
const unclaimState = (freshTicket.escalationTier ?? 0) >= 1 ? 'escalated' : 'unclaimed';
const renameInfo = await canRename(freshTicket);
if (renameInfo.ok) {
try {
await enqueueRename(interaction.channel, makeTicketName(unclaimState, freshTicket, creatorNicknameUnclaim));
} catch (e) {
console.error('Rename error (unclaim):', e);
}
} else {
const unlockAtMs = Date.now() + renameInfo.waitMs;
const unlockAtUnix = Math.floor(unlockAtMs / 1000);
await interaction.channel.send(
`Channel renamed too quickly. Try again <t:${unlockAtUnix}:R>.`
);
}
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] });
}
}
// --- CONFIRM CLOSE ---
async function handleConfirmClose(interaction, ticket) {
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 messages = await interaction.channel.messages.fetch({ limit: 100 });
const log =
`TRANSCRIPT: ${ticket.subject}\nUser: ${ticket.senderEmail}\n---\n` +
messages
.reverse()
.map(
m =>
`[${m.createdAt.toLocaleString()}] ${m.author.tag}: ${m.cleanContent}`
)
.join('\n');
const file = new AttachmentBuilder(Buffer.from(log), {
name: `transcript-${interaction.channel.name}.txt`
});
const channelName = interaction.channel.name;
const opened = new Date(ticket.createdAt);
const openedStr = opened.toLocaleString('en-US', {
month: '2-digit',
day: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: true,
timeZoneName: 'short'
});
const closedStr = closedAt.toLocaleString('en-US', {
month: '2-digit',
day: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: true,
timeZoneName: 'short'
});
// In-ticket message before transcript is posted (Discord close message)
const discordCloseContent = CONFIG.DISCORD_CLOSE_MESSAGE;
await interaction.channel.send(discordCloseContent);
const transcriptChan = await interaction.client.channels
.fetch(CONFIG.TRANSCRIPT_CHAN)
.catch(() => null);
let transcriptMsg = null;
const transcriptContent = CONFIG.DISCORD_TRANSCRIPT_MESSAGE
.replace(/\{channel_name\}/g, channelName)
.replace(/\{email\}/g, ticket.senderEmail || '')
.replace(/\{date_opened\}/g, openedStr)
.replace(/\{date_closed\}/g, closedStr)
+ `\n\nDate Opened: ${openedStr}\nDate Closed: ${closedStr}`;
if (transcriptChan) {
transcriptMsg = await transcriptChan.send({
content: transcriptContent,
files: [file]
});
}
// DM the transcript to the ticket creator (Discord-originated tickets)
if (ticket.gmailThreadId?.startsWith('discord-')) {
const creatorId = ticket.gmailThreadId.split('-').pop();
try {
const creator = await interaction.client.users.fetch(creatorId);
const dmFile = new AttachmentBuilder(Buffer.from(log), {
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) {
console.warn(`Could not DM transcript to user ${creatorId}:`, dmErr.message);
}
}
const logChan = await interaction.client.channels
.fetch(CONFIG.LOG_CHAN)
.catch(() => null);
if (logChan) {
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);
const creatorMention = creator.toString();
logMsg = `Closed ${creatorMention}'s **${channelName}** by ${closerMention} (${closerDisplayName})`;
} catch {
logMsg = `Closed **${channelName}** by ${closerMention} (${closerDisplayName})`;
}
} else {
logMsg = `Closed **${channelName}** (${ticket.senderEmail}) by ${closerMention} (${closerDisplayName})`;
}
await logChan.send(logMsg);
}
const closerDisplayName =
interaction.member?.displayName || interaction.user.username;
if (!ticket.gmailThreadId?.startsWith('discord-')) {
await sendTicketClosedEmail(ticket, closerDisplayName);
}
await Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $set: { discordThreadId: null, status: 'closed' } }
);
try {
const { deleteStaffChannel } = require('../services/staffChannel');
await deleteStaffChannel(interaction.guild, ticket.staffChannelId);
} catch (e) {
console.error('Delete staff channel (close):', e);
}
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(() => {
(async () => {
if (parentCatId && guildRef) {
await cleanupEmptyOverflowCategory(guildRef, parentCatId, CONFIG.TICKET_CATEGORY_NAME);
}
})();
}, 6000);
} catch (e) {
console.error('Close ticket error:', e);
}
}
/**
* Handle the ticket_modal submission (from the open-ticket panel button).
*/
async function handleTicketModal(interaction) {
await interaction.deferReply({ ephemeral: true });
const email = interaction.fields.getTextInputValue('ticket_email').trim();
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 useThread =
interaction.customId === 'ticket_modal_thread' ||
(interaction.customId === 'ticket_modal' && !!CONFIG.DISCORD_THREAD_CHANNEL_ID);
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 creatorNicknameModal = interaction.member?.displayName || interaction.user.username;
const unclaimedName = toDiscordSafeName(`unclaimed-${creatorNicknameModal}-${ticketNumber}`);
let channel;
let parentCategoryIdForTicket = null;
if (useThread && CONFIG.DISCORD_THREAD_CHANNEL_ID) {
try {
channel = await createDiscordTicketAsThread(guild, ticketNumber, interaction.user.id);
parentCategoryIdForTicket = channel.parent?.parentId ?? null;
} catch (err) {
console.error('Discord ticket thread create failed:', err.message);
return interaction.editReply('Could not create ticket thread. Check DISCORD_THREAD_CHANNEL_ID and try again.');
}
} else if (useThread && !CONFIG.DISCORD_THREAD_CHANNEL_ID) {
return interaction.editReply('Thread tickets are not configured (DISCORD_THREAD_CHANNEL_ID is not set). Use a channel panel or set the env variable.');
} else {
let parentId;
try {
parentId = 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.');
}
parentCategoryIdForTicket = parentId;
try {
channel = await guild.channels.create({
name: unclaimedName,
type: ChannelType.GuildText,
parent: parentId,
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 displayName = interaction.member?.displayName || interaction.user.username;
// Welcome embed (dark grey #1e2124)
const welcomeEmbed = new EmbedBuilder()
.setDescription(CONFIG.TICKET_WELCOME_MESSAGE)
.setColor(CONFIG.EMBED_COLOR_INFO)
.setFooter({ text: 'Indifferent Broccoli Tickets' });
// Ticket details embed (dark) short labels, trimmed description
const descTrimmed = description.length > 500 ? description.slice(0, 497) + '…' : description;
const infoEmbed = new EmbedBuilder()
.setColor(CONFIG.EMBED_COLOR_INFO)
.addFields(
{ name: 'Email', value: email, inline: true },
{ name: 'Game', value: game || 'Not specified', inline: true },
{ name: 'Description', value: descTrimmed, inline: false }
)
.setTimestamp();
const actionRow = getTicketActionRow({ escalationTier: 0 });
const welcomeMsg = await channel.send({
content: `Hey There ${interaction.user} 🥦`,
embeds: [welcomeEmbed, infoEmbed],
components: [actionRow]
});
await Ticket.updateOne(
{ discordThreadId: channel.id },
{ $set: { welcomeMessageId: welcomeMsg.id } }
);
await interaction.deleteReply().catch(() => {});
const logChan = await interaction.client.channels.fetch(CONFIG.LOG_CHAN).catch(() => null);
if (logChan) {
await logChan.send(
`📝 ${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.');
}
}
module.exports = { handleButton, handleTicketModal };